Browse Source

feat: Basic CRU on AdjustmentSource

Part of #29
Michael Bromley 7 years ago
parent
commit
b2ec600ba9
43 changed files with 1866 additions and 52 deletions
  1. 4 0
      admin-ui/src/app/app.routes.ts
  2. 1 1
      admin-ui/src/app/common/base-list.component.ts
  3. 87 0
      admin-ui/src/app/common/utilities/interpolate-description.spec.ts
  4. 31 0
      admin-ui/src/app/common/utilities/interpolate-description.ts
  5. 13 0
      admin-ui/src/app/core/components/main-nav/main-nav.component.html
  6. 84 0
      admin-ui/src/app/data/definitions/adjustment-source-definitions.ts
  7. 73 0
      admin-ui/src/app/data/providers/adjustment-source-data.service.ts
  8. 7 0
      admin-ui/src/app/data/providers/data.service.mock.ts
  9. 3 0
      admin-ui/src/app/data/providers/data.service.ts
  10. 45 0
      admin-ui/src/app/marketing/components/adjustment-operation-input/adjustment-operation-input.component.html
  11. 21 0
      admin-ui/src/app/marketing/components/adjustment-operation-input/adjustment-operation-input.component.scss
  12. 124 0
      admin-ui/src/app/marketing/components/adjustment-operation-input/adjustment-operation-input.component.ts
  13. 75 0
      admin-ui/src/app/marketing/components/promotion-detail/promotion-detail.component.html
  14. 0 0
      admin-ui/src/app/marketing/components/promotion-detail/promotion-detail.component.scss
  15. 234 0
      admin-ui/src/app/marketing/components/promotion-detail/promotion-detail.component.ts
  16. 33 0
      admin-ui/src/app/marketing/components/promotion-list/promotion-list.component.html
  17. 0 0
      admin-ui/src/app/marketing/components/promotion-list/promotion-list.component.scss
  18. 25 0
      admin-ui/src/app/marketing/components/promotion-list/promotion-list.component.ts
  19. 17 0
      admin-ui/src/app/marketing/marketing.module.ts
  20. 38 0
      admin-ui/src/app/marketing/marketing.routes.ts
  21. 27 0
      admin-ui/src/app/marketing/providers/routing/promotion-resolver.ts
  22. 1 1
      admin-ui/src/app/shared/components/affixed-input/affixed-input.component.html
  23. 73 0
      admin-ui/src/app/shared/components/affixed-input/percentage-suffix-input.component.ts
  24. 2 0
      admin-ui/src/app/shared/shared.module.ts
  25. 1 1
      admin-ui/src/browserslist
  26. 14 1
      admin-ui/src/i18n-messages/en.json
  27. 8 0
      admin-ui/src/styles/styles.scss
  28. 0 0
      schema.json
  29. 4 0
      server/e2e/__snapshots__/administrator.e2e-spec.ts.snap
  30. 2 0
      server/src/api/api.module.ts
  31. 65 0
      server/src/api/resolvers/adjustment-source.resolver.ts
  32. 40 0
      server/src/api/types/adjustment-source.api.graphql
  33. 6 0
      server/src/common/types/common-types.graphql
  34. 5 0
      server/src/common/types/permission.graphql
  35. 27 0
      server/src/config/adjustment/adjustment-actions.ts
  36. 38 0
      server/src/config/adjustment/adjustment-conditions.ts
  37. 8 4
      server/src/entity/adjustment-source/adjustment-source.entity.ts
  38. 34 0
      server/src/entity/adjustment-source/adjustment-source.graphql
  39. 2 1
      server/src/entity/adjustment/adjustment.entity.ts
  40. 2 0
      server/src/entity/order/order.entity.ts
  41. 209 0
      server/src/service/providers/adjustment-source.service.ts
  42. 2 0
      server/src/service/service.module.ts
  43. 381 43
      shared/generated-types.ts

+ 4 - 0
admin-ui/src/app/app.routes.ts

@@ -31,6 +31,10 @@ export const routes: Route[] = [
                 path: 'orders',
                 loadChildren: './order/order.module#OrderModule',
             },
+            {
+                path: 'marketing',
+                loadChildren: './marketing/marketing.module#MarketingModule',
+            },
         ],
     },
 ];

+ 1 - 1
admin-ui/src/app/common/base-list.component.ts

@@ -5,7 +5,7 @@ import { map, takeUntil } from 'rxjs/operators';
 
 import { QueryResult } from '../data/query-result';
 
-export type ListQueryFn<R> = (...args: any[]) => QueryResult<R>;
+export type ListQueryFn<R> = (...args: any[]) => QueryResult<R, any>;
 export type MappingFn<T, R> = (result: R) => { items: T[]; totalItems: number };
 
 /**

+ 87 - 0
admin-ui/src/app/common/utilities/interpolate-description.spec.ts

@@ -0,0 +1,87 @@
+import { AdjustmentOperation } from 'shared/generated-types';
+
+import { interpolateDescription } from './interpolate-description';
+
+describe('interpolateDescription()', () => {
+    it('works for single argument', () => {
+        const operation: Partial<AdjustmentOperation> = {
+            args: [{ name: 'foo', type: 'string' }],
+            description: 'The value is { foo }',
+        };
+        const result = interpolateDescription(operation as any, { foo: 'val' });
+
+        expect(result).toBe('The value is val');
+    });
+
+    it('works for multiple arguments', () => {
+        const operation: Partial<AdjustmentOperation> = {
+            args: [{ name: 'foo', type: 'string' }, { name: 'bar', type: 'string' }],
+            description: 'The value is { foo } and { bar }',
+        };
+        const result = interpolateDescription(operation as any, { foo: 'val1', bar: 'val2' });
+
+        expect(result).toBe('The value is val1 and val2');
+    });
+
+    it('is case-insensitive', () => {
+        const operation: Partial<AdjustmentOperation> = {
+            args: [{ name: 'foo', type: 'string' }],
+            description: 'The value is { FOo }',
+        };
+        const result = interpolateDescription(operation as any, { foo: 'val' });
+
+        expect(result).toBe('The value is val');
+    });
+
+    it('ignores whitespaces in interpolation', () => {
+        const operation: Partial<AdjustmentOperation> = {
+            args: [{ name: 'foo', type: 'string' }, { name: 'bar', type: 'string' }],
+            description: 'The value is {foo} and {      bar    }',
+        };
+        const result = interpolateDescription(operation as any, { foo: 'val1', bar: 'val2' });
+
+        expect(result).toBe('The value is val1 and val2');
+    });
+
+    it('formats money as a decimal', () => {
+        const operation: Partial<AdjustmentOperation> = {
+            args: [{ name: 'price', type: 'money' }],
+            description: 'The price is { price }',
+        };
+        const result = interpolateDescription(operation as any, { price: 1234 });
+
+        expect(result).toBe('The price is 12.34');
+    });
+
+    it('formats Date object as human-readable', () => {
+        const operation: Partial<AdjustmentOperation> = {
+            args: [{ name: 'date', type: 'datetime' }],
+            description: 'The date is { date }',
+        };
+        const date = new Date('2017-09-15 00:00:00');
+        const result = interpolateDescription(operation as any, { date });
+
+        expect(result).toBe(`The date is ${date.toLocaleDateString()}`);
+    });
+
+    it('formats date string object as human-readable', () => {
+        const operation: Partial<AdjustmentOperation> = {
+            args: [{ name: 'date', type: 'datetime' }],
+            description: 'The date is { date }',
+        };
+        const date = '2017-09-15';
+        const result = interpolateDescription(operation as any, { date });
+
+        expect(result).toBe(`The date is 2017-09-15`);
+    });
+
+    it('correctly interprets falsy-looking values', () => {
+        const operation: Partial<AdjustmentOperation> = {
+            args: [{ name: 'foo', type: 'int' }],
+            description: 'The value is { foo }',
+        };
+        const result = interpolateDescription(operation as any, { foo: 0 });
+
+        expect(result).toBe(`The value is 0`);
+    });
+});

+ 31 - 0
admin-ui/src/app/common/utilities/interpolate-description.ts

@@ -0,0 +1,31 @@
+import { AdjustmentOperation } from 'shared/generated-types';
+
+/**
+ * Interpolates the description of an AdjustmentOperation with the given values.
+ */
+export function interpolateDescription(
+    operation: AdjustmentOperation,
+    values: { [name: string]: any },
+): string {
+    if (!operation) {
+        return '';
+    }
+    const templateString = operation.description;
+    const interpolated = templateString.replace(/{\s*([a-zA-Z0-9]+)\s*}/gi, (substring, argName: string) => {
+        const normalizedArgName = argName.toLowerCase();
+        const value = values[normalizedArgName];
+        if (value == null) {
+            return '_';
+        }
+        let formatted = value;
+        const argDef = operation.args.find(arg => arg.name === normalizedArgName);
+        if (argDef && argDef.type === 'money') {
+            formatted = value / 100;
+        }
+        if (argDef && argDef.type === 'datetime' && value instanceof Date) {
+            formatted = value.toLocaleDateString();
+        }
+        return formatted;
+    });
+    return interpolated;
+}

+ 13 - 0
admin-ui/src/app/core/components/main-nav/main-nav.component.html

@@ -44,6 +44,19 @@
                 </li>
             </ul>
         </section>
+        <section class="nav-group">
+            <input id="tabexample2" type="checkbox">
+            <label for="tabexample2">{{ 'nav.marketing' | translate }}</label>
+            <ul class="nav-list">
+                <li>
+                    <a class="nav-link"
+                       [routerLink]="['/marketing', 'promotions']"
+                       routerLinkActive="active">
+                        <clr-icon shape="asterisk" size="20"></clr-icon>{{ 'nav.promotions' | translate }}
+                    </a>
+                </li>
+            </ul>
+        </section>
         <section class="nav-group">
             <input id="tabexample2" type="checkbox">
             <label for="tabexample2">{{ 'nav.administrator' | translate }}</label>

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

@@ -0,0 +1,84 @@
+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
+        type
+        name
+        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}
+`;

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

@@ -0,0 +1,73 @@
+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.baseDataService.query<GetAdjustmentSourceList.Query, GetAdjustmentSourceList.Variables>(
+            GET_ADJUSTMENT_SOURCE_LIST,
+            {
+                type: AdjustmentType.PROMOTION,
+                options: {
+                    take,
+                    skip,
+                },
+            },
+        );
+    }
+
+    getPromotion(id: string) {
+        return this.baseDataService.query<GetAdjustmentSource.Query, GetAdjustmentSource.Variables>(
+            GET_ADJUSTMENT_SOURCE,
+            {
+                id,
+            },
+        );
+    }
+
+    getAdjustmentOperations(type: AdjustmentType) {
+        return this.baseDataService.query<GetAdjustmentOperations.Query, GetAdjustmentOperations.Variables>(
+            GET_ADJUSTMENT_OPERATIONS,
+            {
+                type,
+            },
+        );
+    }
+
+    createPromotion(input: CreateAdjustmentSourceInput) {
+        return this.baseDataService.mutate<CreateAdjustmentSource.Mutation, CreateAdjustmentSource.Variables>(
+            CREATE_ADJUSTMENT_SOURCE,
+            {
+                input,
+            },
+        );
+    }
+
+    updatePromotion(input: UpdateAdjustmentSourceInput) {
+        return this.baseDataService.mutate<UpdateAdjustmentSource.Mutation, UpdateAdjustmentSource.Variables>(
+            UPDATE_ADJUSTMENT_SOURCE,
+            {
+                input,
+            },
+        );
+    }
+}

+ 7 - 0
admin-ui/src/app/data/providers/data.service.mock.ts

@@ -30,6 +30,13 @@ export function spyObservable(name: string, returnValue: any = {}): jasmine.Spy
 }
 
 export class MockDataService implements DataServiceMock {
+    adjustmentSource = {
+        getPromotions: spyQueryResult('getPromotions'),
+        getPromotion: spyQueryResult('getPromotion'),
+        getAdjustmentOperations: spyQueryResult('getAdjustmentOperations'),
+        createPromotion: spyObservable('createPromotion'),
+        updatePromotion: spyObservable('updatePromotion'),
+    };
     administrator = {
         getAdministrators: spyQueryResult('getAdministrators'),
         getAdministrator: spyQueryResult('getAdministrator'),

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

@@ -1,5 +1,6 @@
 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';
@@ -10,6 +11,7 @@ import { ProductDataService } from './product-data.service';
 
 @Injectable()
 export class DataService {
+    adjustmentSource: AdjustmentSourceDataService;
     administrator: AdministratorDataService;
     auth: AuthDataService;
     product: ProductDataService;
@@ -18,6 +20,7 @@ export class DataService {
     order: OrderDataService;
 
     constructor(baseDataService: BaseDataService) {
+        this.adjustmentSource = new AdjustmentSourceDataService(baseDataService);
         this.administrator = new AdministratorDataService(baseDataService);
         this.auth = new AuthDataService(baseDataService);
         this.product = new ProductDataService(baseDataService);

+ 45 - 0
admin-ui/src/app/marketing/components/adjustment-operation-input/adjustment-operation-input.component.html

@@ -0,0 +1,45 @@
+<div class="card">
+    <div class="card-block">
+        {{ interpolateDescription() }}
+    </div>
+    <div class="card-block">
+        <form clrForm
+              [formGroup]="form"
+              *ngIf="operation"
+              class="operation-inputs">
+
+            <clr-input-container *ngFor="let arg of operation.args">
+                <label>{{ arg.name | titlecase }}</label>
+
+                <input *ngIf="arg.type === 'int'"
+                       clrInput [name]="arg.name"
+                       type="number"
+                       step="1"
+                       [formControlName]="arg.name">
+                <input *ngIf="arg.type === 'string'"
+                       clrInput [name]="arg.name"
+                       type="text"
+                       [formControlName]="arg.name">
+                <input *ngIf="arg.type === 'datetime'"
+                       clrInput [name]="arg.name"
+                       type="date"
+                       [formControlName]="arg.name">
+                <vdr-currency-input *ngIf="arg.type === 'money'"
+                                    clrInput
+                                    [formControlName]="arg.name"></vdr-currency-input>
+                <vdr-percentage-suffix-input *ngIf="arg.type === 'percentage'"
+                                             [formControlName]="arg.name"
+                                             clrInput></vdr-percentage-suffix-input>
+
+                <clr-control-error>{{ 'error.this-field-is-required' | translate }}</clr-control-error>
+            </clr-input-container>
+
+        </form>
+    </div>
+    <div class="card-footer">
+        <button class="btn btn-sm btn-link btn-warning"
+                (click)="remove.emit(operation)">
+            <clr-icon shape="times"></clr-icon> {{ 'common.remove' | translate }}
+        </button>
+    </div>
+</div>

+ 21 - 0
admin-ui/src/app/marketing/components/adjustment-operation-input/adjustment-operation-input.component.scss

@@ -0,0 +1,21 @@
+:host {
+    display: block;
+    margin-bottom: 12px;
+}
+
+.operation-inputs {
+    display: flex;
+    flex-wrap: wrap;
+
+    .clr-form-control {
+        margin-top: 0;
+    }
+
+    clr-input-container:not(:last-child) {
+        margin-right: 12px;
+    }
+
+    .hidden {
+        display: none;
+    }
+}

+ 124 - 0
admin-ui/src/app/marketing/components/adjustment-operation-input/adjustment-operation-input.component.ts

@@ -0,0 +1,124 @@
+import {
+    ChangeDetectionStrategy,
+    Component,
+    EventEmitter,
+    forwardRef,
+    Input,
+    OnChanges,
+    OnDestroy,
+    Output,
+    SimpleChanges,
+} from '@angular/core';
+import {
+    AbstractControl,
+    ControlValueAccessor,
+    FormControl,
+    FormGroup,
+    NG_VALIDATORS,
+    NG_VALUE_ACCESSOR,
+    ValidationErrors,
+    Validator,
+    Validators,
+} from '@angular/forms';
+import { Subscription } from 'rxjs';
+import { AdjustmentOperation } from 'shared/generated-types';
+
+import { interpolateDescription } from '../../../common/utilities/interpolate-description';
+
+/**
+ * A form input which renders a card with the internal form fields of the given AdjustmentOperation.
+ */
+@Component({
+    selector: 'vdr-adjustment-operation-input',
+    templateUrl: './adjustment-operation-input.component.html',
+    styleUrls: ['./adjustment-operation-input.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+    providers: [
+        {
+            provide: NG_VALUE_ACCESSOR,
+            useExisting: AdjustmentOperationInputComponent,
+            multi: true,
+        },
+        {
+            provide: NG_VALIDATORS,
+            useExisting: forwardRef(() => AdjustmentOperationInputComponent),
+            multi: true,
+        },
+    ],
+})
+export class AdjustmentOperationInputComponent
+    implements OnChanges, OnDestroy, ControlValueAccessor, Validator {
+    @Input() operation: AdjustmentOperation;
+    @Output() remove = new EventEmitter<AdjustmentOperation>();
+    argValues: { [name: string]: any } = {};
+    onChange: (val: any) => void;
+    onTouch: () => void;
+    form = new FormGroup({});
+    private subscription: Subscription;
+
+    interpolateDescription(): string {
+        return interpolateDescription(this.operation, this.form.value);
+    }
+
+    ngOnChanges(changes: SimpleChanges) {
+        if ('operation' in changes) {
+            this.createForm();
+        }
+    }
+
+    ngOnDestroy() {
+        if (this.subscription) {
+            this.subscription.unsubscribe();
+        }
+    }
+
+    registerOnChange(fn: any) {
+        this.onChange = fn;
+    }
+
+    registerOnTouched(fn: any) {
+        this.onTouch = fn;
+    }
+
+    setDisabledState(isDisabled: boolean) {
+        if (isDisabled) {
+            this.form.disable();
+        } else {
+            this.form.enable();
+        }
+    }
+
+    writeValue(value: any): void {
+        this.form.patchValue(value);
+    }
+
+    private createForm() {
+        if (this.subscription) {
+            this.subscription.unsubscribe();
+        }
+        this.form = new FormGroup({});
+        for (const arg of this.operation.args) {
+            this.form.addControl(arg.name, new FormControl(arg.value, Validators.required));
+        }
+        this.subscription = this.form.valueChanges.subscribe(value => {
+            if (this.onChange) {
+                this.onChange({
+                    code: this.operation.code,
+                    args: value,
+                });
+            }
+            if (this.onTouch) {
+                this.onTouch();
+            }
+        });
+    }
+
+    validate(c: AbstractControl): ValidationErrors | null {
+        if (this.form.invalid) {
+            return {
+                required: true,
+            };
+        }
+        return null;
+    }
+}

+ 75 - 0
admin-ui/src/app/marketing/components/promotion-detail/promotion-detail.component.html

@@ -0,0 +1,75 @@
+<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]="promotionForm" >
+    <section class="form-block">
+        <vdr-form-field [label]="'common.name' | translate" for="name">
+            <input id="name" type="text" formControlName="name">
+        </vdr-form-field>
+    </section>
+
+    <div class="clr-row">
+        <div class="clr-col" formArrayName="conditions">
+            <label>{{ 'marketing.conditions' | translate }}</label>
+            <ng-container *ngFor="let condition of conditions; index as i">
+                <vdr-adjustment-operation-input (remove)="removeCondition($event)"
+                                                [operation]="condition"
+                                                [formControlName]="i"></vdr-adjustment-operation-input>
+            </ng-container>
+
+            <div>
+                <clr-dropdown>
+                    <div clrDropdownTrigger>
+                        <button class="btn btn-outline">
+                            <clr-icon shape="plus"></clr-icon> {{ 'marketing.add-condition' | translate }}
+                        </button>
+                    </div>
+                    <clr-dropdown-menu clrPosition="top-right" *clrIfOpen>
+                        <button *ngFor="let condition of getAvailableConditions()"
+                                type="button"
+                                clrDropdownItem (click)="addCondition(condition)">
+                            {{ condition.code }}
+                        </button>
+                    </clr-dropdown-menu>
+                </clr-dropdown>
+            </div>
+        </div>
+        <div class="clr-col" formArrayName="actions">
+            <label>{{ 'marketing.actions' | translate }}</label>
+            <vdr-adjustment-operation-input *ngFor="let action of actions; index as i"
+                                            (remove)="removeAction($event)"
+                                            [operation]="action"
+                                            [formControlName]="i"></vdr-adjustment-operation-input>
+            <div>
+                <clr-dropdown>
+                    <div clrDropdownTrigger>
+                        <button class="btn btn-outline">
+                            <clr-icon shape="plus"></clr-icon> {{ 'marketing.add-action' | translate }}
+                        </button>
+                    </div>
+                    <clr-dropdown-menu clrPosition="top-right" *clrIfOpen>
+                        <button *ngFor="let action of getAvailableActions()"
+                                type="button"
+                                clrDropdownItem (click)="addAction(action)">
+                            {{ action.code }}
+                        </button>
+                    </clr-dropdown-menu>
+                </clr-dropdown>
+            </div>
+        </div>
+    </div>
+</form>

+ 0 - 0
admin-ui/src/app/marketing/components/promotion-detail/promotion-detail.component.scss


+ 234 - 0
admin-ui/src/app/marketing/components/promotion-detail/promotion-detail.component.ts

@@ -0,0 +1,234 @@
+import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
+import { FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms';
+import { ActivatedRoute, Router } from '@angular/router';
+import { Observable } from 'rxjs';
+import { mergeMap, take } from 'rxjs/operators';
+import {
+    AdjustmentOperation,
+    AdjustmentOperationInput,
+    AdjustmentSource,
+    AdjustmentType,
+    CreateAdjustmentSourceInput,
+    LanguageCode,
+    UpdateAdjustmentSourceInput,
+} 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-promotion-detail',
+    templateUrl: './promotion-detail.component.html',
+    styleUrls: ['./promotion-detail.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class PromotionDetailComponent extends BaseDetailComponent<AdjustmentSource.Fragment>
+    implements OnInit, OnDestroy {
+    promotion$: Observable<AdjustmentSource.Fragment>;
+    promotionForm: FormGroup;
+    conditions: AdjustmentOperation[] = [];
+    actions: AdjustmentOperation[] = [];
+
+    private allConditions: AdjustmentOperation[];
+    private allActions: AdjustmentOperation[];
+
+    constructor(
+        router: Router,
+        route: ActivatedRoute,
+        serverConfigService: ServerConfigService,
+        private changeDetector: ChangeDetectorRef,
+        private dataService: DataService,
+        private formBuilder: FormBuilder,
+        private notificationService: NotificationService,
+    ) {
+        super(route, router, serverConfigService);
+        this.promotionForm = this.formBuilder.group({
+            name: ['', Validators.required],
+            conditions: this.formBuilder.array([]),
+            actions: this.formBuilder.array([]),
+        });
+    }
+
+    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;
+            });
+    }
+
+    ngOnDestroy() {
+        this.destroy();
+    }
+
+    getAvailableConditions(): AdjustmentOperation[] {
+        return this.allConditions.filter(o => !this.conditions.find(c => c.code === o.code));
+    }
+
+    getAvailableActions(): AdjustmentOperation[] {
+        return this.allActions.filter(o => !this.actions.find(a => a.code === o.code));
+    }
+
+    saveButtonEnabled(): boolean {
+        return (
+            this.promotionForm.dirty &&
+            this.promotionForm.valid &&
+            this.conditions.length !== 0 &&
+            this.actions.length !== 0
+        );
+    }
+
+    addCondition(condition: AdjustmentOperation) {
+        this.addOperation('conditions', condition);
+        this.promotionForm.markAsDirty();
+    }
+
+    addAction(action: AdjustmentOperation) {
+        this.addOperation('actions', action);
+        this.promotionForm.markAsDirty();
+    }
+
+    removeCondition(condition: AdjustmentOperation) {
+        this.removeOperation('conditions', condition);
+        this.promotionForm.markAsDirty();
+    }
+
+    removeAction(action: AdjustmentOperation) {
+        this.removeOperation('actions', action);
+        this.promotionForm.markAsDirty();
+    }
+
+    formArrayOf(key: 'conditions' | 'actions'): FormArray {
+        return this.promotionForm.get(key) as FormArray;
+    }
+
+    create() {
+        if (!this.promotionForm.dirty) {
+            return;
+        }
+        const formValue = this.promotionForm.value;
+        const input: CreateAdjustmentSourceInput = {
+            name: formValue.name,
+            type: AdjustmentType.PROMOTION,
+            conditions: this.mapOperationsToInputs(this.conditions, formValue),
+            actions: this.mapOperationsToInputs(this.actions, formValue),
+        };
+        this.dataService.adjustmentSource.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 });
+            },
+            err => {
+                this.notificationService.error(_('common.notify-create-error'), {
+                    entity: 'Promotion',
+                });
+            },
+        );
+    }
+
+    save() {
+        if (!this.promotionForm.dirty) {
+            return;
+        }
+        const formValue = this.promotionForm.value;
+        this.promotion$
+            .pipe(
+                take(1),
+                mergeMap(promotion => {
+                    const input: UpdateAdjustmentSourceInput = {
+                        id: promotion.id,
+                        name: formValue.name,
+                        conditions: this.mapOperationsToInputs(this.conditions, formValue),
+                        actions: this.mapOperationsToInputs(this.actions, formValue),
+                    };
+                    return this.dataService.adjustmentSource.updatePromotion(input);
+                }),
+            )
+            .subscribe(
+                data => {
+                    this.notificationService.success(_('common.notify-update-success'), {
+                        entity: 'Promotion',
+                    });
+                    this.promotionForm.markAsPristine();
+                    this.changeDetector.markForCheck();
+                },
+                err => {
+                    this.notificationService.error(_('common.notify-update-error'), {
+                        entity: 'Promotion',
+                    });
+                },
+            );
+    }
+
+    /**
+     * Update the form values when the entity changes.
+     */
+    protected setFormValues(entity: AdjustmentSource.Fragment, languageCode: LanguageCode): void {
+        this.promotionForm.patchValue({ name: entity.name });
+        entity.conditions.forEach(o => {
+            this.addOperation('conditions', o);
+        });
+        entity.actions.forEach(o => this.addOperation('actions', o));
+    }
+
+    /**
+     * Maps an array of conditions or actions to the input format expected by the GraphQL API.
+     */
+    private mapOperationsToInputs(
+        operations: AdjustmentOperation[],
+        formValue: any,
+    ): AdjustmentOperationInput[] {
+        return operations.map((o, i) => {
+            return {
+                code: o.code,
+                arguments: Object.values(formValue.conditions[i].args).map(v => v.toString()),
+            };
+        });
+    }
+
+    /**
+     * Adds a new condition or action to the promotion.
+     */
+    private addOperation(key: 'conditions' | 'actions', operation: AdjustmentOperation) {
+        const operationsArray = this.formArrayOf(key);
+        const collection = key === 'conditions' ? this.conditions : this.actions;
+        const index = operationsArray.value.findIndex(o => o.code === operation.code);
+        if (index === -1) {
+            const argsHash = operation.args.reduce(
+                (output, arg) => ({
+                    ...output,
+                    [arg.name]: arg.value,
+                }),
+                {},
+            );
+            operationsArray.push(
+                this.formBuilder.control({
+                    code: operation.code,
+                    args: argsHash,
+                }),
+            );
+            collection.push(operation);
+        }
+    }
+
+    /**
+     * Removes a condition or action from the promotion.
+     */
+    private removeOperation(key: 'conditions' | 'actions', operation: AdjustmentOperation) {
+        const operationsArray = this.formArrayOf(key);
+        const collection = key === 'conditions' ? this.conditions : this.actions;
+        const index = operationsArray.value.findIndex(o => o.code === operation.code);
+        if (index !== -1) {
+            operationsArray.removeAt(index);
+            collection.splice(index, 1);
+        }
+    }
+}

+ 33 - 0
admin-ui/src/app/marketing/components/promotion-list/promotion-list.component.html

@@ -0,0 +1,33 @@
+<vdr-action-bar>
+    <vdr-ab-right>
+        <a class="btn btn-primary" [routerLink]="['./create']">
+            <clr-icon shape="plus"></clr-icon>
+            {{ 'marketing.create-new-promotion' | 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.code' | translate }}</vdr-dt-column>
+    <vdr-dt-column>{{ 'common.created-at' | translate }}</vdr-dt-column>
+    <vdr-dt-column>{{ 'common.updated-at' | translate }}</vdr-dt-column>
+    <vdr-dt-column></vdr-dt-column>
+    <ng-template let-promotion="item">
+        <td class="left">{{ promotion.id }}</td>
+        <td class="left">{{ promotion.name }}</td>
+        <td class="left">{{ promotion.createdAt }}</td>
+        <td class="left">{{ promotion.updatedAt }}</td>
+        <td class="right">
+            <vdr-table-row-action iconShape="edit"
+                                  [label]="'common.edit' | translate"
+                                  [linkTo]="['./', promotion.id]">
+            </vdr-table-row-action>
+        </td>
+    </ng-template>
+</vdr-data-table>

+ 0 - 0
admin-ui/src/app/marketing/components/promotion-list/promotion-list.component.scss


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

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

+ 17 - 0
admin-ui/src/app/marketing/marketing.module.ts

@@ -0,0 +1,17 @@
+import { NgModule } from '@angular/core';
+import { RouterModule } from '@angular/router';
+
+import { SharedModule } from '../shared/shared.module';
+
+import { AdjustmentOperationInputComponent } from './components/adjustment-operation-input/adjustment-operation-input.component';
+import { PromotionDetailComponent } from './components/promotion-detail/promotion-detail.component';
+import { PromotionListComponent } from './components/promotion-list/promotion-list.component';
+import { marketingRoutes } from './marketing.routes';
+import { PromotionResolver } from './providers/routing/promotion-resolver';
+
+@NgModule({
+    imports: [SharedModule, RouterModule.forChild(marketingRoutes)],
+    declarations: [PromotionListComponent, PromotionDetailComponent, AdjustmentOperationInputComponent],
+    providers: [PromotionResolver],
+})
+export class MarketingModule {}

+ 38 - 0
admin-ui/src/app/marketing/marketing.routes.ts

@@ -0,0 +1,38 @@
+import { Route } from '@angular/router';
+import { AdjustmentSource } from 'shared/generated-types';
+
+import { createResolveData } from '../common/base-entity-resolver';
+import { detailBreadcrumb } from '../common/detail-breadcrumb';
+import { _ } from '../core/providers/i18n/mark-for-extraction';
+
+import { PromotionDetailComponent } from './components/promotion-detail/promotion-detail.component';
+import { PromotionListComponent } from './components/promotion-list/promotion-list.component';
+import { PromotionResolver } from './providers/routing/promotion-resolver';
+
+export const marketingRoutes: Route[] = [
+    {
+        path: 'promotions',
+        component: PromotionListComponent,
+        data: {
+            breadcrumb: _('breadcrumb.promotions'),
+        },
+    },
+    {
+        path: 'promotions/:id',
+        component: PromotionDetailComponent,
+        resolve: createResolveData(PromotionResolver),
+        data: {
+            breadcrumb: promotionBreadcrumb,
+        },
+    },
+];
+
+export function promotionBreadcrumb(data: any, params: any) {
+    return detailBreadcrumb<AdjustmentSource.Fragment>({
+        entity: data.entity,
+        id: params.id,
+        breadcrumbKey: 'breadcrumb.promotions',
+        getName: promotion => promotion.name,
+        route: 'promotions',
+    });
+}

+ 27 - 0
admin-ui/src/app/marketing/providers/routing/promotion-resolver.ts

@@ -0,0 +1,27 @@
+import { Injectable } from '@angular/core';
+import { AdjustmentSource, AdjustmentType } 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 PromotionResolver extends BaseEntityResolver<AdjustmentSource.Fragment> {
+    constructor(private dataService: DataService) {
+        super(
+            {
+                __typename: 'AdjustmentSource',
+                id: '',
+                createdAt: '',
+                updatedAt: '',
+                type: AdjustmentType.PROMOTION,
+                name: '',
+                conditions: [],
+                actions: [],
+            },
+            id => this.dataService.adjustmentSource.getPromotion(id).mapStream(data => data.adjustmentSource),
+        );
+    }
+}

+ 1 - 1
admin-ui/src/app/shared/components/affixed-input/affixed-input.component.html

@@ -1,4 +1,4 @@
-<ng-content select="input"></ng-content>
+<ng-content></ng-content>
 <div class="affix prefix" *ngIf="prefix">
     {{ prefix }}
 </div>

+ 73 - 0
admin-ui/src/app/shared/components/affixed-input/percentage-suffix-input.component.ts

@@ -0,0 +1,73 @@
+import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core';
+import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
+
+import { getDefaultCurrency } from '../../../common/utilities/get-default-currency';
+
+/**
+ * A form input control which displays a number input with a percentage sign suffix.
+ */
+@Component({
+    selector: 'vdr-percentage-suffix-input',
+    styles: [
+        `
+            :host {
+                padding: 0;
+            }
+        `,
+    ],
+    template: `
+        <vdr-affixed-input suffix="%">
+            <input type="number"
+                   step="1"
+                   [value]="_value"
+                   [disabled]="disabled"
+                   [readonly]="readonly"
+                   (input)="onInput($event.target.value)"
+                   (focus)="onTouch()">
+        </vdr-affixed-input>
+    `,
+    providers: [
+        {
+            provide: NG_VALUE_ACCESSOR,
+            useExisting: PercentageSuffixInputComponent,
+            multi: true,
+        },
+    ],
+})
+export class PercentageSuffixInputComponent implements ControlValueAccessor, OnChanges {
+    @Input() disabled = false;
+    @Input() readonly = false;
+    @Input() value: number;
+    onChange: (val: any) => void;
+    onTouch: () => void;
+    _value: number;
+
+    ngOnChanges(changes: SimpleChanges) {
+        if ('value' in changes) {
+            this.writeValue(changes['value'].currentValue);
+        }
+    }
+
+    registerOnChange(fn: any) {
+        this.onChange = fn;
+    }
+
+    registerOnTouched(fn: any) {
+        this.onTouch = fn;
+    }
+
+    setDisabledState(isDisabled: boolean) {
+        this.disabled = isDisabled;
+    }
+
+    onInput(value: string | number) {
+        this.onChange(value);
+    }
+
+    writeValue(value: any): void {
+        const numericValue = +value;
+        if (!Number.isNaN(numericValue)) {
+            this._value = numericValue;
+        }
+    }
+}

+ 2 - 0
admin-ui/src/app/shared/shared.module.ts

@@ -13,6 +13,7 @@ import {
     ActionBarRightComponent,
 } from './components/action-bar/action-bar.component';
 import { AffixedInputComponent } from './components/affixed-input/affixed-input.component';
+import { PercentageSuffixInputComponent } from './components/affixed-input/percentage-suffix-input.component';
 import { ChipComponent } from './components/chip/chip.component';
 import { CurrencyInputComponent } from './components/currency-input/currency-input.component';
 import { CustomFieldControlComponent } from './components/custom-field-control/custom-field-control.component';
@@ -63,6 +64,7 @@ const DECLARATIONS = [
     FormFieldControlDirective,
     FormItemComponent,
     ModalDialogComponent,
+    PercentageSuffixInputComponent,
     DialogComponentOutletComponent,
     DialogButtonsDirective,
     DialogTitleDirective,

+ 1 - 1
admin-ui/src/browserslist

@@ -6,4 +6,4 @@
 last 2 versions
 Firefox ESR
 not dead
-# IE 9-11
+# IE 9-11

+ 14 - 1
admin-ui/src/i18n-messages/en.json

@@ -27,6 +27,7 @@
     "facets": "Facets",
     "orders": "Orders",
     "products": "Products",
+    "promotions": "Promotions",
     "roles": "Roles"
   },
   "catalog": {
@@ -92,6 +93,7 @@
     "language": "Language",
     "log-out": "Log out",
     "login": "Log in",
+    "name": "Name",
     "next": "Next",
     "notify-create-error": "An error occurred, could not create { entity }",
     "notify-create-success": "Created new { entity }",
@@ -99,6 +101,7 @@
     "notify-update-success": "Updated { entity }",
     "password": "Password",
     "remember-me": "Remember me",
+    "remove": "Remove",
     "update": "Update",
     "updated-at": "Updated at",
     "username": "Username"
@@ -108,7 +111,15 @@
     "403-forbidden": "Your session has expired. Please log in",
     "could-not-connect-to-server": "Could not connect to the Vendure server at { url }",
     "facet-value-form-values-do-not-match": "The number of values in the facet form does not match the actual number of values",
-    "product-variant-form-values-do-not-match": "The number of variants in the product form does not match the actual number of variants"
+    "product-variant-form-values-do-not-match": "The number of variants in the product form does not match the actual number of variants",
+    "this-field-is-required": "This field is required"
+  },
+  "marketing": {
+    "actions": "Actions",
+    "add-action": "Add action",
+    "add-condition": "Add condition",
+    "conditions": "Conditions",
+    "create-new-promotion": "Create new promotion"
   },
   "nav": {
     "administrator": "Admin",
@@ -117,8 +128,10 @@
     "catalog": "Catalog",
     "categories": "Categories",
     "facets": "Facets",
+    "marketing": "Marketing",
     "orders": "Orders",
     "products": "Products",
+    "promotions": "Promotions",
     "roles": "Roles",
     "sales": "Sales"
   },

+ 8 - 0
admin-ui/src/styles/styles.scss

@@ -5,3 +5,11 @@
 a:link, a:visited {
     color: darken($color-brand, 20%);
 }
+
+.btn-link.btn-warning {
+    color: $color-warning;
+
+    &:hover {
+        color: darken($color-warning, 20%);
+    }
+}

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


+ 4 - 0
server/e2e/__snapshots__/administrator.e2e-spec.ts.snap

@@ -35,6 +35,10 @@ Object {
           "ReadOrder",
           "UpdateOrder",
           "DeleteOrder",
+          "CreateAdjustmentSource",
+          "ReadAdjustmentSource",
+          "UpdateAdjustmentSource",
+          "DeleteAdjustmentSource",
         ],
       },
     ],

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

@@ -11,6 +11,7 @@ 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';
@@ -24,6 +25,7 @@ import { ProductResolver } from './resolvers/product.resolver';
 import { RoleResolver } from './resolvers/role.resolver';
 
 const exportedProviders = [
+    AdjustmentSourceResolver,
     AdministratorResolver,
     AuthResolver,
     AssetResolver,

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

@@ -0,0 +1,65 @@
+import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
+import {
+    AdjustmentOperationsQueryArgs,
+    AdjustmentSource as ResolvedAdjustmentSource,
+    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<ResolvedAdjustmentSource>> {
+        return this.adjustmentSourceService.findAll(args.type, args.options || undefined);
+    }
+
+    @Query()
+    @Allow(Permission.ReadAdjustmentSource)
+    adjustmentSource(
+        @Ctx() ctx: RequestContext,
+        @Args() args: AdjustmentSourceQueryArgs,
+    ): Promise<ResolvedAdjustmentSource | 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<ResolvedAdjustmentSource> {
+        return this.adjustmentSourceService.createAdjustmentSource(ctx, args.input);
+    }
+
+    @Mutation()
+    @Allow(Permission.UpdateAdjustmentSource)
+    updateAdjustmentSource(
+        @Ctx() ctx: RequestContext,
+        @Args() args: UpdateAdjustmentSourceMutationArgs,
+    ): Promise<ResolvedAdjustmentSource> {
+        return this.adjustmentSourceService.updateAdjustmentSource(ctx, args.input);
+    }
+}

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

@@ -0,0 +1,40 @@
+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
+}

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

@@ -12,6 +12,12 @@ interface Node {
     id: ID!
 }
 
+enum AdjustmentType {
+    TAX
+    PROMOTION
+    SHIPPING
+}
+
 enum SortOrder {
     ASC
     DESC

+ 5 - 0
server/src/common/types/permission.graphql

@@ -26,4 +26,9 @@ enum Permission {
     ReadOrder
     UpdateOrder
     DeleteOrder
+
+    CreateAdjustmentSource
+    ReadAdjustmentSource
+    UpdateAdjustmentSource
+    DeleteAdjustmentSource
 }

+ 27 - 0
server/src/config/adjustment/adjustment-actions.ts

@@ -0,0 +1,27 @@
+import { AdjustmentOperation, AdjustmentType } from 'shared/generated-types';
+
+import { OrderItem } from '../../entity/order-item/order-item.entity';
+import { Order } from '../../entity/order/order.entity';
+
+export type AdjustmentActionArgType = 'percentage' | 'money';
+export type AdjustmentActionArg = { name: string; type: AdjustmentActionArgType; value?: string };
+export type AdjustmentActionCalculation<T extends OrderItem | Order> = (
+    target: T,
+    args: { [argName: string]: any },
+    context: any,
+) => number;
+
+export interface AdjustmentActionConfig<T extends OrderItem | Order> extends AdjustmentOperation {
+    args: AdjustmentActionArg[];
+    calculate: AdjustmentActionCalculation<T>;
+}
+
+export const orderPercentageDiscount: AdjustmentActionConfig<Order> = {
+    type: AdjustmentType.PROMOTION,
+    code: 'order_percentage_discount',
+    args: [{ name: 'discount', type: 'percentage' }],
+    calculate(target, args) {
+        return target.price * args.discount;
+    },
+    description: 'Discount order by { discount }%',
+};

+ 38 - 0
server/src/config/adjustment/adjustment-conditions.ts

@@ -0,0 +1,38 @@
+import { AdjustmentOperation, AdjustmentType } from 'shared/generated-types';
+
+import { OrderItem } from '../../entity/order-item/order-item.entity';
+import { Order } from '../../entity/order/order.entity';
+
+export type AdjustmentConditionArgType = 'int' | 'money' | 'string' | 'datetime';
+export type AdjustmentConditionArg = { name: string; type: AdjustmentConditionArgType };
+export type AdjustmentConditionPredicate<T extends OrderItem | Order> = (
+    target: T,
+    args: { [argName: string]: any },
+    context: any,
+) => boolean;
+
+export interface AdjustmentConditionConfig<T extends OrderItem | Order> extends AdjustmentOperation {
+    args: AdjustmentConditionArg[];
+    predicate: AdjustmentConditionPredicate<T>;
+}
+
+export const minimumOrderAmount: AdjustmentConditionConfig<Order> = {
+    type: AdjustmentType.PROMOTION,
+    code: 'minimum_order_amount',
+    args: [{ name: 'amount', type: 'money' }],
+    predicate(target: Order, args) {
+        return target.price >= args.amount;
+    },
+    description: 'If order total is greater than { amount }',
+};
+
+export const dateRange: AdjustmentConditionConfig<Order> = {
+    type: AdjustmentType.PROMOTION,
+    code: 'date_range',
+    args: [{ name: 'start', type: 'datetime' }, { name: 'end', type: 'datetime' }],
+    predicate(target: Order, args) {
+        const now = Date.now();
+        return args.start < now && now < args.end;
+    },
+    description: 'If Order placed between { start } and { end }',
+};

+ 8 - 4
server/src/entity/adjustment-source/adjustment-source.entity.ts

@@ -1,3 +1,4 @@
+import { AdjustmentType } from 'shared/generated-types';
 import { DeepPartial } from 'shared/shared-types';
 import { Column, Entity, JoinTable, ManyToMany } from 'typeorm';
 
@@ -5,10 +6,9 @@ import { ChannelAware } from '../../common/types/common-types';
 import { VendureEntity } from '../base/base.entity';
 import { Channel } from '../channel/channel.entity';
 
-export enum AdjustmentType {
-    Tax,
-    Shipping,
-    Promotion,
+export interface AdjustmentOperationValues {
+    code: string;
+    args: Array<string | number | Date>;
 }
 
 @Entity()
@@ -24,4 +24,8 @@ export class AdjustmentSource extends VendureEntity implements ChannelAware {
     @ManyToMany(type => Channel)
     @JoinTable()
     channels: Channel[];
+
+    @Column('simple-json') conditions: AdjustmentOperationValues[];
+
+    @Column('simple-json') actions: AdjustmentOperationValues[];
 }

+ 34 - 0
server/src/entity/adjustment-source/adjustment-source.graphql

@@ -2,6 +2,40 @@ type AdjustmentSource implements Node {
     id: ID!
     createdAt: DateTime!
     updatedAt: DateTime!
+    name: String!
+    type: AdjustmentType!
+    conditions: [AdjustmentOperation!]!
+    actions: [AdjustmentOperation!]!
+}
+
+type AdjustmentArg {
     name: String!
     type: String!
+    value: String
+}
+
+type AdjustmentOperation {
+    type: AdjustmentType!
+    code: String!
+    args: [AdjustmentArg!]!
+    description: String!
+}
+
+input AdjustmentOperationInput {
+    code: String!
+    arguments: [String!]!
+}
+
+input CreateAdjustmentSourceInput {
+    name: String!
+    type: AdjustmentType!
+    conditions: [AdjustmentOperationInput!]!
+    actions: [AdjustmentOperationInput!]!
+}
+
+input UpdateAdjustmentSourceInput {
+    id: ID!
+    name: String
+    conditions: [AdjustmentOperationInput!]
+    actions: [AdjustmentOperationInput!]
 }

+ 2 - 1
server/src/entity/adjustment/adjustment.entity.ts

@@ -1,6 +1,7 @@
+import { AdjustmentType } from 'shared/generated-types';
 import { Column, Entity, ManyToOne, TableInheritance } from 'typeorm';
 
-import { AdjustmentSource, AdjustmentType } from '../adjustment-source/adjustment-source.entity';
+import { AdjustmentSource } from '../adjustment-source/adjustment-source.entity';
 import { VendureEntity } from '../base/base.entity';
 
 @Entity()

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

@@ -22,4 +22,6 @@ export class Order extends VendureEntity {
 
     @OneToMany(type => OrderAdjustment, adjustment => adjustment.target)
     adjustments: OrderAdjustment[];
+
+    price: number;
 }

+ 209 - 0
server/src/service/providers/adjustment-source.service.ts

@@ -0,0 +1,209 @@
+import { Injectable } from '@nestjs/common';
+import { InjectConnection } from '@nestjs/typeorm';
+import {
+    AdjustmentOperation,
+    AdjustmentOperationInput,
+    AdjustmentSource as ResolvedAdjustmentSource,
+    AdjustmentType,
+    CreateAdjustmentSourceInput,
+    UpdateAdjustmentSourceInput,
+} from 'shared/generated-types';
+import { omit } from 'shared/omit';
+import { pick } from 'shared/pick';
+import { ID, PaginatedList } from 'shared/shared-types';
+import { assertNever } from 'shared/shared-utils';
+import { Connection } from 'typeorm';
+
+import { RequestContext } from '../../api/common/request-context';
+import { ListQueryOptions } from '../../common/types/common-types';
+import { assertFound } from '../../common/utils';
+import {
+    AdjustmentActionArgType,
+    AdjustmentActionConfig,
+    orderPercentageDiscount,
+} from '../../config/adjustment/adjustment-actions';
+import {
+    AdjustmentConditionArgType,
+    AdjustmentConditionConfig,
+    dateRange,
+    minimumOrderAmount,
+} from '../../config/adjustment/adjustment-conditions';
+import {
+    AdjustmentOperationValues,
+    AdjustmentSource,
+} from '../../entity/adjustment-source/adjustment-source.entity';
+import { I18nError } from '../../i18n/i18n-error';
+import { buildListQuery } from '../helpers/build-list-query';
+import { patchEntity } from '../helpers/patch-entity';
+
+import { ChannelService } from './channel.service';
+
+@Injectable()
+export class AdjustmentSourceService {
+    availableConditions: Array<AdjustmentConditionConfig<any>> = [];
+    availableActions: Array<AdjustmentActionConfig<any>> = [];
+
+    constructor(@InjectConnection() private connection: Connection, private channelService: ChannelService) {
+        // TODO: get from config
+        this.availableConditions = [minimumOrderAmount, dateRange];
+        this.availableActions = [orderPercentageDiscount];
+    }
+
+    findAll(
+        type: AdjustmentType,
+        options?: ListQueryOptions<AdjustmentSource>,
+    ): Promise<PaginatedList<ResolvedAdjustmentSource>> {
+        return buildListQuery(this.connection, AdjustmentSource, options)
+            .getManyAndCount()
+            .then(([items, totalItems]) => ({
+                items: items.map(i => this.asResolvedAdjustmentSource(i)),
+                totalItems,
+            }));
+    }
+
+    async findOne(adjustmentSourceId: ID): Promise<ResolvedAdjustmentSource | undefined> {
+        const adjustmentSource = await this.connection.manager.findOne(AdjustmentSource, adjustmentSourceId, {
+            relations: [],
+        });
+        if (adjustmentSource) {
+            return this.asResolvedAdjustmentSource(adjustmentSource);
+        }
+    }
+
+    /**
+     * Returns all available AdjustmentOperations.
+     */
+    getAdjustmentOperations(
+        type: AdjustmentType,
+    ): {
+        conditions: Array<AdjustmentConditionConfig<any>>;
+        actions: Array<AdjustmentActionConfig<any>>;
+    } {
+        return {
+            conditions: this.availableConditions.filter(o => o.type === type),
+            actions: this.availableActions.filter(o => o.type === type),
+        };
+    }
+
+    async createAdjustmentSource(
+        ctx: RequestContext,
+        input: CreateAdjustmentSourceInput,
+    ): Promise<ResolvedAdjustmentSource> {
+        const adjustmentSource = new AdjustmentSource({
+            name: input.name,
+            type: input.type,
+            conditions: input.conditions.map(c => this.parseOperationArgs('condition', c)),
+            actions: input.actions.map(a => this.parseOperationArgs('action', a)),
+        });
+        this.channelService.assignToChannels(adjustmentSource, ctx);
+        const newAdjustmentSource = await this.connection.manager.save(adjustmentSource);
+        return assertFound(this.findOne(newAdjustmentSource.id));
+    }
+
+    async updateAdjustmentSource(
+        ctx: RequestContext,
+        input: UpdateAdjustmentSourceInput,
+    ): Promise<ResolvedAdjustmentSource> {
+        const adjustmentSource = await this.connection.getRepository(AdjustmentSource).findOne(input.id);
+        if (!adjustmentSource) {
+            throw new I18nError(`error.entity-with-id-not-found`, {
+                entityName: 'AdjustmentSource',
+                id: input.id,
+            });
+        }
+        const updatedAdjustmentSource = patchEntity(adjustmentSource, omit(input, ['conditions', 'actions']));
+        if (input.conditions) {
+            updatedAdjustmentSource.conditions = input.conditions.map(c =>
+                this.parseOperationArgs('condition', c),
+            );
+        }
+        if (input.actions) {
+            updatedAdjustmentSource.actions = input.actions.map(a => this.parseOperationArgs('action', a));
+        }
+        await this.connection.manager.save(updatedAdjustmentSource);
+        return assertFound(this.findOne(updatedAdjustmentSource.id));
+    }
+
+    /**
+     * The internal entity (AdjustmentSource) differs from the object returned by the GraphQL API (ResolvedAdjustmentSource) in that
+     * the external object combines the AdjustmentOperation definition with the argument values. This method augments
+     * an AdjustmentSource entity so that it fits the ResolvedAdjustmentSource interface.
+     */
+    private asResolvedAdjustmentSource(adjustmentSource: AdjustmentSource): ResolvedAdjustmentSource {
+        const output = {
+            ...pick(adjustmentSource, ['id', 'createdAt', 'updatedAt', 'name', 'type']),
+            ...{
+                conditions: this.mapOperationValuesToOperation('condition', adjustmentSource.conditions),
+                actions: this.mapOperationValuesToOperation('action', adjustmentSource.actions),
+            },
+        };
+        return output;
+    }
+
+    private mapOperationValuesToOperation(
+        type: 'condition' | 'action',
+        values: AdjustmentOperationValues[],
+    ): AdjustmentOperation[] {
+        return values.map(v => {
+            const match = this.getAdjustmentOperationByCode(type, v.code);
+            return {
+                type: match.type,
+                code: v.code,
+                args: match.args.map((args, i) => ({
+                    ...args,
+                    value: !!v.args[i] ? v.args[i].toString() : '',
+                })),
+                description: match.description,
+            };
+        });
+    }
+
+    /**
+     * Converts the input values of the "create" and "update" mutations into the format expected by the AdjustmentSource entity.
+     */
+    private parseOperationArgs(
+        type: 'condition' | 'action',
+        input: AdjustmentOperationInput,
+    ): AdjustmentOperationValues {
+        const match = this.getAdjustmentOperationByCode(type, input.code);
+        const output: AdjustmentOperationValues = {
+            code: input.code,
+            args: input.arguments.map((inputArg, i) => {
+                return this.castArgument(inputArg, match.args[i].type as any);
+            }),
+        };
+        return output;
+    }
+
+    private getAdjustmentOperationByCode(type: 'condition' | 'action', code: string): AdjustmentOperation {
+        const available: AdjustmentOperation[] =
+            type === 'condition' ? this.availableConditions : this.availableActions;
+        const match = available.find(a => a.code === code);
+        if (!match) {
+            throw new I18nError(`error.adjustment-source-with-code-not-found`, { code });
+        }
+        return match;
+    }
+
+    /**
+     * Input arguments are always received as strings, but for certain parameter types they
+     * should be cast to a different type.
+     */
+    private castArgument(
+        inputArg: string,
+        type: AdjustmentConditionArgType | AdjustmentActionArgType,
+    ): string | number {
+        switch (type) {
+            case 'string':
+            case 'datetime':
+                return inputArg;
+            case 'money':
+            case 'int':
+            case 'percentage':
+                return Number.parseInt(inputArg, 10);
+            default:
+                assertNever(type);
+                return inputArg;
+        }
+    }
+}

+ 2 - 0
server/src/service/service.module.ts

@@ -5,6 +5,7 @@ import { ConfigModule } from '../config/config.module';
 import { getConfig } from '../config/vendure-config';
 
 import { TranslationUpdaterService } from './helpers/translation-updater.service';
+import { AdjustmentSourceService } from './providers/adjustment-source.service';
 import { AdministratorService } from './providers/administrator.service';
 import { AssetService } from './providers/asset.service';
 import { AuthService } from './providers/auth.service';
@@ -21,6 +22,7 @@ import { ProductService } from './providers/product.service';
 import { RoleService } from './providers/role.service';
 
 const exportedProviders = [
+    AdjustmentSourceService,
     AdministratorService,
     AssetService,
     AuthService,

+ 381 - 43
shared/generated-types.ts

@@ -29,16 +29,19 @@ export type Json = any;
 
 export type Upload = any;
 
+export interface Node {
+    id: string;
+}
+
 export interface PaginatedList {
     items: Node[];
     totalItems: number;
 }
 
-export interface Node {
-    id: string;
-}
-
 export interface Query {
+    adjustmentSource?: AdjustmentSource | null;
+    adjustmentSources: AdjustmentSourceList;
+    adjustmentOperations: AdjustmentOperations;
     administrators: AdministratorList;
     administrator?: Administrator | null;
     assets: AssetList;
@@ -62,6 +65,39 @@ export interface Query {
     uiState: UiState;
 }
 
+export interface AdjustmentSource extends Node {
+    id: string;
+    createdAt: DateTime;
+    updatedAt: DateTime;
+    name: string;
+    type: AdjustmentType;
+    conditions: AdjustmentOperation[];
+    actions: AdjustmentOperation[];
+}
+
+export interface AdjustmentOperation {
+    type: AdjustmentType;
+    code: string;
+    args: AdjustmentArg[];
+    description: string;
+}
+
+export interface AdjustmentArg {
+    name: string;
+    type: string;
+    value?: string | null;
+}
+
+export interface AdjustmentSourceList extends PaginatedList {
+    items: AdjustmentSource[];
+    totalItems: number;
+}
+
+export interface AdjustmentOperations {
+    conditions: AdjustmentOperation[];
+    actions: AdjustmentOperation[];
+}
+
 export interface AdministratorList extends PaginatedList {
     items: Administrator[];
     totalItems: number;
@@ -223,7 +259,7 @@ export interface Order extends Node {
     createdAt: DateTime;
     updatedAt: DateTime;
     code: string;
-    customer: Customer;
+    customer?: Customer | null;
     items: OrderItem[];
     adjustments: Adjustment[];
 }
@@ -290,14 +326,6 @@ export interface Adjustment extends Node {
     source: AdjustmentSource;
 }
 
-export interface AdjustmentSource extends Node {
-    id: string;
-    createdAt: DateTime;
-    updatedAt: DateTime;
-    name: string;
-    type: string;
-}
-
 export interface OrderList extends PaginatedList {
     items: Order[];
     totalItems: number;
@@ -385,6 +413,8 @@ export interface UiState {
 }
 
 export interface Mutation {
+    createAdjustmentSource: AdjustmentSource;
+    updateAdjustmentSource: AdjustmentSource;
     createAdministrator: Administrator;
     updateAdministrator: Administrator;
     assignRoleToAdministrator: Administrator;
@@ -423,26 +453,22 @@ export interface LoginResult {
     user: CurrentUser;
 }
 
-export interface AdministratorListOptions {
+export interface AdjustmentSourceListOptions {
     take?: number | null;
     skip?: number | null;
-    sort?: AdministratorSortParameter | null;
-    filter?: AdministratorFilterParameter | null;
+    sort?: AdjustmentSourceSortParameter | null;
+    filter?: AdjustmentSourceFilterParameter | null;
 }
 
-export interface AdministratorSortParameter {
+export interface AdjustmentSourceSortParameter {
     id?: SortOrder | null;
     createdAt?: SortOrder | null;
     updatedAt?: SortOrder | null;
-    firstName?: SortOrder | null;
-    lastName?: SortOrder | null;
-    emailAddress?: SortOrder | null;
+    name?: SortOrder | null;
 }
 
-export interface AdministratorFilterParameter {
-    firstName?: StringOperators | null;
-    lastName?: StringOperators | null;
-    emailAddress?: StringOperators | null;
+export interface AdjustmentSourceFilterParameter {
+    name?: StringOperators | null;
     createdAt?: DateOperators | null;
     updatedAt?: DateOperators | null;
 }
@@ -464,6 +490,30 @@ export interface DateRange {
     end: DateTime;
 }
 
+export interface AdministratorListOptions {
+    take?: number | null;
+    skip?: number | null;
+    sort?: AdministratorSortParameter | null;
+    filter?: AdministratorFilterParameter | null;
+}
+
+export interface AdministratorSortParameter {
+    id?: SortOrder | null;
+    createdAt?: SortOrder | null;
+    updatedAt?: SortOrder | null;
+    firstName?: SortOrder | null;
+    lastName?: SortOrder | null;
+    emailAddress?: SortOrder | null;
+}
+
+export interface AdministratorFilterParameter {
+    firstName?: StringOperators | null;
+    lastName?: StringOperators | null;
+    emailAddress?: StringOperators | null;
+    createdAt?: DateOperators | null;
+    updatedAt?: DateOperators | null;
+}
+
 export interface AssetListOptions {
     take?: number | null;
     skip?: number | null;
@@ -614,6 +664,25 @@ export interface RoleFilterParameter {
     updatedAt?: DateOperators | null;
 }
 
+export interface CreateAdjustmentSourceInput {
+    name: string;
+    type: AdjustmentType;
+    conditions: AdjustmentOperationInput[];
+    actions: AdjustmentOperationInput[];
+}
+
+export interface AdjustmentOperationInput {
+    code: string;
+    arguments: string[];
+}
+
+export interface UpdateAdjustmentSourceInput {
+    id: string;
+    name?: string | null;
+    conditions?: AdjustmentOperationInput[] | null;
+    actions?: AdjustmentOperationInput[] | null;
+}
+
 export interface CreateAdministratorInput {
     firstName: string;
     lastName: string;
@@ -844,6 +913,16 @@ export interface ProductOptionTranslationInput {
     name?: string | null;
     customFields?: Json | null;
 }
+export interface AdjustmentSourceQueryArgs {
+    id: string;
+}
+export interface AdjustmentSourcesQueryArgs {
+    type: AdjustmentType;
+    options?: AdjustmentSourceListOptions | null;
+}
+export interface AdjustmentOperationsQueryArgs {
+    type: AdjustmentType;
+}
 export interface AdministratorsQueryArgs {
     options?: AdministratorListOptions | null;
 }
@@ -898,6 +977,12 @@ export interface RolesQueryArgs {
 export interface RoleQueryArgs {
     id: string;
 }
+export interface CreateAdjustmentSourceMutationArgs {
+    input: CreateAdjustmentSourceInput;
+}
+export interface UpdateAdjustmentSourceMutationArgs {
+    input: UpdateAdjustmentSourceInput;
+}
 export interface CreateAdministratorMutationArgs {
     input: CreateAdministratorInput;
 }
@@ -996,6 +1081,12 @@ export interface SetUiLanguageMutationArgs {
     languageCode?: LanguageCode | null;
 }
 
+export enum AdjustmentType {
+    TAX = 'TAX',
+    PROMOTION = 'PROMOTION',
+    SHIPPING = 'SHIPPING',
+}
+
 export enum SortOrder {
     ASC = 'ASC',
     DESC = 'DESC',
@@ -1021,6 +1112,10 @@ export enum Permission {
     ReadOrder = 'ReadOrder',
     UpdateOrder = 'UpdateOrder',
     DeleteOrder = 'DeleteOrder',
+    CreateAdjustmentSource = 'CreateAdjustmentSource',
+    ReadAdjustmentSource = 'ReadAdjustmentSource',
+    UpdateAdjustmentSource = 'UpdateAdjustmentSource',
+    DeleteAdjustmentSource = 'DeleteAdjustmentSource',
 }
 
 export enum AssetType {
@@ -1220,6 +1315,9 @@ export type AdjustmentTarget = Order | OrderItem;
 
 export namespace QueryResolvers {
     export interface Resolvers<Context = any> {
+        adjustmentSource?: AdjustmentSourceResolver<AdjustmentSource | null, any, Context>;
+        adjustmentSources?: AdjustmentSourcesResolver<AdjustmentSourceList, any, Context>;
+        adjustmentOperations?: AdjustmentOperationsResolver<AdjustmentOperations, any, Context>;
         administrators?: AdministratorsResolver<AdministratorList, any, Context>;
         administrator?: AdministratorResolver<Administrator | null, any, Context>;
         assets?: AssetsResolver<AssetList, any, Context>;
@@ -1243,6 +1341,36 @@ export namespace QueryResolvers {
         uiState?: UiStateResolver<UiState, any, Context>;
     }
 
+    export type AdjustmentSourceResolver<R = AdjustmentSource | null, Parent = any, Context = any> = Resolver<
+        R,
+        Parent,
+        Context,
+        AdjustmentSourceArgs
+    >;
+    export interface AdjustmentSourceArgs {
+        id: string;
+    }
+
+    export type AdjustmentSourcesResolver<R = AdjustmentSourceList, Parent = any, Context = any> = Resolver<
+        R,
+        Parent,
+        Context,
+        AdjustmentSourcesArgs
+    >;
+    export interface AdjustmentSourcesArgs {
+        type: AdjustmentType;
+        options?: AdjustmentSourceListOptions | null;
+    }
+
+    export type AdjustmentOperationsResolver<
+        R = AdjustmentOperations,
+        Parent = any,
+        Context = any
+    > = Resolver<R, Parent, Context, AdjustmentOperationsArgs>;
+    export interface AdjustmentOperationsArgs {
+        type: AdjustmentType;
+    }
+
     export type AdministratorsResolver<R = AdministratorList, Parent = any, Context = any> = Resolver<
         R,
         Parent,
@@ -1427,6 +1555,92 @@ export namespace QueryResolvers {
     export type UiStateResolver<R = UiState, Parent = any, Context = any> = Resolver<R, Parent, Context>;
 }
 
+export namespace AdjustmentSourceResolvers {
+    export interface Resolvers<Context = any> {
+        id?: IdResolver<string, any, Context>;
+        createdAt?: CreatedAtResolver<DateTime, any, Context>;
+        updatedAt?: UpdatedAtResolver<DateTime, any, Context>;
+        name?: NameResolver<string, any, Context>;
+        type?: TypeResolver<AdjustmentType, any, Context>;
+        conditions?: ConditionsResolver<AdjustmentOperation[], any, Context>;
+        actions?: ActionsResolver<AdjustmentOperation[], any, Context>;
+    }
+
+    export type IdResolver<R = string, Parent = any, Context = any> = Resolver<R, Parent, Context>;
+    export type CreatedAtResolver<R = DateTime, Parent = any, Context = any> = Resolver<R, Parent, Context>;
+    export type UpdatedAtResolver<R = DateTime, Parent = any, Context = any> = Resolver<R, Parent, Context>;
+    export type NameResolver<R = string, Parent = any, Context = any> = Resolver<R, Parent, Context>;
+    export type TypeResolver<R = AdjustmentType, Parent = any, Context = any> = Resolver<R, Parent, Context>;
+    export type ConditionsResolver<R = AdjustmentOperation[], Parent = any, Context = any> = Resolver<
+        R,
+        Parent,
+        Context
+    >;
+    export type ActionsResolver<R = AdjustmentOperation[], Parent = any, Context = any> = Resolver<
+        R,
+        Parent,
+        Context
+    >;
+}
+
+export namespace AdjustmentOperationResolvers {
+    export interface Resolvers<Context = any> {
+        type?: TypeResolver<AdjustmentType, any, Context>;
+        code?: CodeResolver<string, any, Context>;
+        args?: ArgsResolver<AdjustmentArg[], any, Context>;
+        description?: DescriptionResolver<string, any, Context>;
+    }
+
+    export type TypeResolver<R = AdjustmentType, Parent = any, Context = any> = Resolver<R, Parent, Context>;
+    export type CodeResolver<R = string, Parent = any, Context = any> = Resolver<R, Parent, Context>;
+    export type ArgsResolver<R = AdjustmentArg[], Parent = any, Context = any> = Resolver<R, Parent, Context>;
+    export type DescriptionResolver<R = string, Parent = any, Context = any> = Resolver<R, Parent, Context>;
+}
+
+export namespace AdjustmentArgResolvers {
+    export interface Resolvers<Context = any> {
+        name?: NameResolver<string, any, Context>;
+        type?: TypeResolver<string, any, Context>;
+        value?: ValueResolver<string | null, any, Context>;
+    }
+
+    export type NameResolver<R = string, Parent = any, Context = any> = Resolver<R, Parent, Context>;
+    export type TypeResolver<R = string, Parent = any, Context = any> = Resolver<R, Parent, Context>;
+    export type ValueResolver<R = string | null, Parent = any, Context = any> = Resolver<R, Parent, Context>;
+}
+
+export namespace AdjustmentSourceListResolvers {
+    export interface Resolvers<Context = any> {
+        items?: ItemsResolver<AdjustmentSource[], any, Context>;
+        totalItems?: TotalItemsResolver<number, any, Context>;
+    }
+
+    export type ItemsResolver<R = AdjustmentSource[], Parent = any, Context = any> = Resolver<
+        R,
+        Parent,
+        Context
+    >;
+    export type TotalItemsResolver<R = number, Parent = any, Context = any> = Resolver<R, Parent, Context>;
+}
+
+export namespace AdjustmentOperationsResolvers {
+    export interface Resolvers<Context = any> {
+        conditions?: ConditionsResolver<AdjustmentOperation[], any, Context>;
+        actions?: ActionsResolver<AdjustmentOperation[], any, Context>;
+    }
+
+    export type ConditionsResolver<R = AdjustmentOperation[], Parent = any, Context = any> = Resolver<
+        R,
+        Parent,
+        Context
+    >;
+    export type ActionsResolver<R = AdjustmentOperation[], Parent = any, Context = any> = Resolver<
+        R,
+        Parent,
+        Context
+    >;
+}
+
 export namespace AdministratorListResolvers {
     export interface Resolvers<Context = any> {
         items?: ItemsResolver<Administrator[], any, Context>;
@@ -1865,7 +2079,7 @@ export namespace OrderResolvers {
         createdAt?: CreatedAtResolver<DateTime, any, Context>;
         updatedAt?: UpdatedAtResolver<DateTime, any, Context>;
         code?: CodeResolver<string, any, Context>;
-        customer?: CustomerResolver<Customer, any, Context>;
+        customer?: CustomerResolver<Customer | null, any, Context>;
         items?: ItemsResolver<OrderItem[], any, Context>;
         adjustments?: AdjustmentsResolver<Adjustment[], any, Context>;
     }
@@ -1874,7 +2088,11 @@ export namespace OrderResolvers {
     export type CreatedAtResolver<R = DateTime, Parent = any, Context = any> = Resolver<R, Parent, Context>;
     export type UpdatedAtResolver<R = DateTime, Parent = any, Context = any> = Resolver<R, Parent, Context>;
     export type CodeResolver<R = string, Parent = any, Context = any> = Resolver<R, Parent, Context>;
-    export type CustomerResolver<R = Customer, Parent = any, Context = any> = Resolver<R, Parent, Context>;
+    export type CustomerResolver<R = Customer | null, Parent = any, Context = any> = Resolver<
+        R,
+        Parent,
+        Context
+    >;
     export type ItemsResolver<R = OrderItem[], Parent = any, Context = any> = Resolver<R, Parent, Context>;
     export type AdjustmentsResolver<R = Adjustment[], Parent = any, Context = any> = Resolver<
         R,
@@ -2063,22 +2281,6 @@ export namespace AdjustmentResolvers {
     >;
 }
 
-export namespace AdjustmentSourceResolvers {
-    export interface Resolvers<Context = any> {
-        id?: IdResolver<string, any, Context>;
-        createdAt?: CreatedAtResolver<DateTime, any, Context>;
-        updatedAt?: UpdatedAtResolver<DateTime, any, Context>;
-        name?: NameResolver<string, any, Context>;
-        type?: TypeResolver<string, any, Context>;
-    }
-
-    export type IdResolver<R = string, Parent = any, Context = any> = Resolver<R, Parent, Context>;
-    export type CreatedAtResolver<R = DateTime, Parent = any, Context = any> = Resolver<R, Parent, Context>;
-    export type UpdatedAtResolver<R = DateTime, Parent = any, Context = any> = Resolver<R, Parent, Context>;
-    export type NameResolver<R = string, Parent = any, Context = any> = Resolver<R, Parent, Context>;
-    export type TypeResolver<R = string, Parent = any, Context = any> = Resolver<R, Parent, Context>;
-}
-
 export namespace OrderListResolvers {
     export interface Resolvers<Context = any> {
         items?: ItemsResolver<Order[], any, Context>;
@@ -2329,6 +2531,8 @@ export namespace UiStateResolvers {
 
 export namespace MutationResolvers {
     export interface Resolvers<Context = any> {
+        createAdjustmentSource?: CreateAdjustmentSourceResolver<AdjustmentSource, any, Context>;
+        updateAdjustmentSource?: UpdateAdjustmentSourceResolver<AdjustmentSource, any, Context>;
         createAdministrator?: CreateAdministratorResolver<Administrator, any, Context>;
         updateAdministrator?: UpdateAdministratorResolver<Administrator, any, Context>;
         assignRoleToAdministrator?: AssignRoleToAdministratorResolver<Administrator, any, Context>;
@@ -2367,6 +2571,26 @@ export namespace MutationResolvers {
         setUiLanguage?: SetUiLanguageResolver<LanguageCode | null, any, Context>;
     }
 
+    export type CreateAdjustmentSourceResolver<R = AdjustmentSource, Parent = any, Context = any> = Resolver<
+        R,
+        Parent,
+        Context,
+        CreateAdjustmentSourceArgs
+    >;
+    export interface CreateAdjustmentSourceArgs {
+        input: CreateAdjustmentSourceInput;
+    }
+
+    export type UpdateAdjustmentSourceResolver<R = AdjustmentSource, Parent = any, Context = any> = Resolver<
+        R,
+        Parent,
+        Context,
+        UpdateAdjustmentSourceArgs
+    >;
+    export interface UpdateAdjustmentSourceArgs {
+        input: UpdateAdjustmentSourceInput;
+    }
+
     export type CreateAdministratorResolver<R = Administrator, Parent = any, Context = any> = Resolver<
         R,
         Parent,
@@ -2681,6 +2905,86 @@ export namespace LoginResultResolvers {
     export type UserResolver<R = CurrentUser, Parent = any, Context = any> = Resolver<R, Parent, Context>;
 }
 
+export namespace GetAdjustmentSourceList {
+    export type Variables = {
+        type: AdjustmentType;
+        options?: AdjustmentSourceListOptions | null;
+    };
+
+    export type Query = {
+        __typename?: 'Query';
+        adjustmentSources: AdjustmentSources;
+    };
+
+    export type AdjustmentSources = {
+        __typename?: 'AdjustmentSourceList';
+        items: Items[];
+        totalItems: number;
+    };
+
+    export type Items = AdjustmentSource.Fragment;
+}
+
+export namespace GetAdjustmentSource {
+    export type Variables = {
+        id: string;
+    };
+
+    export type Query = {
+        __typename?: 'Query';
+        adjustmentSource?: AdjustmentSource | null;
+    };
+
+    export type AdjustmentSource = AdjustmentSource.Fragment;
+}
+
+export namespace GetAdjustmentOperations {
+    export type Variables = {
+        type: AdjustmentType;
+    };
+
+    export type Query = {
+        __typename?: 'Query';
+        adjustmentOperations: AdjustmentOperations;
+    };
+
+    export type AdjustmentOperations = {
+        __typename?: 'AdjustmentOperations';
+        actions: Actions[];
+        conditions: Conditions[];
+    };
+
+    export type Actions = AdjustmentOperation.Fragment;
+
+    export type Conditions = AdjustmentOperation.Fragment;
+}
+
+export namespace CreateAdjustmentSource {
+    export type Variables = {
+        input: CreateAdjustmentSourceInput;
+    };
+
+    export type Mutation = {
+        __typename?: 'Mutation';
+        createAdjustmentSource: CreateAdjustmentSource;
+    };
+
+    export type CreateAdjustmentSource = AdjustmentSource.Fragment;
+}
+
+export namespace UpdateAdjustmentSource {
+    export type Variables = {
+        input: UpdateAdjustmentSourceInput;
+    };
+
+    export type Mutation = {
+        __typename?: 'Mutation';
+        updateAdjustmentSource: UpdateAdjustmentSource;
+    };
+
+    export type UpdateAdjustmentSource = AdjustmentSource.Fragment;
+}
+
 export namespace GetAdministrators {
     export type Variables = {
         options?: AdministratorListOptions | null;
@@ -3323,6 +3627,40 @@ export namespace CreateAssets {
     export type CreateAssets = Asset.Fragment;
 }
 
+export namespace AdjustmentOperation {
+    export type Fragment = {
+        __typename?: 'AdjustmentOperation';
+        args: Args[];
+        code: string;
+        description: string;
+        type: AdjustmentType;
+    };
+
+    export type Args = {
+        __typename?: 'AdjustmentArg';
+        name: string;
+        type: string;
+        value?: string | null;
+    };
+}
+
+export namespace AdjustmentSource {
+    export type Fragment = {
+        __typename?: 'AdjustmentSource';
+        id: string;
+        createdAt: DateTime;
+        updatedAt: DateTime;
+        type: AdjustmentType;
+        name: string;
+        conditions: Conditions[];
+        actions: Actions[];
+    };
+
+    export type Conditions = AdjustmentOperation.Fragment;
+
+    export type Actions = AdjustmentOperation.Fragment;
+}
+
 export namespace Administrator {
     export type Fragment = {
         __typename?: 'Administrator';
@@ -3423,7 +3761,7 @@ export namespace Order {
         createdAt: DateTime;
         updatedAt: DateTime;
         code: string;
-        customer: Customer;
+        customer?: Customer | null;
     };
 
     export type Customer = {

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