diff --git a/admin-ui/src/app/catalog/components/product-detail/product-detail.component.html b/admin-ui/src/app/catalog/components/product-detail/product-detail.component.html index 73a24b6ef5..2967a253db 100644 --- a/admin-ui/src/app/catalog/components/product-detail/product-detail.component.html +++ b/admin-ui/src/app/catalog/components/product-detail/product-detail.component.html @@ -74,7 +74,7 @@ [productVariantsFormArray]="productForm.get('variants')" [taxCategories]="taxCategories$ | async" #productVariantsList> - diff --git a/admin-ui/src/app/catalog/components/product-detail/product-detail.component.ts b/admin-ui/src/app/catalog/components/product-detail/product-detail.component.ts index a223315978..4bcaf9f0cc 100644 --- a/admin-ui/src/app/catalog/components/product-detail/product-detail.component.ts +++ b/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; variants$: Observable; - taxCategories$: Observable; + taxCategories$: Observable; customFields: CustomFieldConfig[]; customVariantFields: CustomFieldConfig[]; productForm: FormGroup; @@ -69,9 +69,9 @@ export class ProductDetailComponent extends BaseDetailComponent 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
-
- - - - -
@@ -63,10 +54,18 @@ + [formControl]="formArray.get([i, 'price'])">
+
+ + + +
+
+ {{ formArray.get([i, 'price']).value * ( 1 + variant.taxRateApplied.value / 100) }} +
diff --git a/admin-ui/src/app/catalog/components/product-variants-list/product-variants-list.component.ts b/admin-ui/src/app/catalog/components/product-variants-list/product-variants-list.component.ts index 7c74f34d0b..f89d0c565d 100644 --- a/admin-ui/src/app/catalog/components/product-variants-list/product-variants-list.component.ts +++ b/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.`); - } } diff --git a/admin-ui/src/app/catalog/components/product-variants-wizard/product-variants-wizard.component.spec.ts b/admin-ui/src/app/catalog/components/product-variants-wizard/product-variants-wizard.component.spec.ts index 8ee21a609e..6658fdfbc5 100644 --- a/admin-ui/src/app/catalog/components/product-variants-wizard/product-variants-wizard.component.spec.ts +++ b/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(); diff --git a/admin-ui/src/app/catalog/components/select-option-group/select-option-group.component.spec.ts b/admin-ui/src/app/catalog/components/select-option-group/select-option-group.component.spec.ts index 313be0dfa0..573d708bf1 100644 --- a/admin-ui/src/app/catalog/components/select-option-group/select-option-group.component.spec.ts +++ b/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(); })); diff --git a/admin-ui/src/app/core/components/main-nav/main-nav.component.html b/admin-ui/src/app/core/components/main-nav/main-nav.component.html index bb042d7985..aa9d8e7cdb 100644 --- a/admin-ui/src/app/core/components/main-nav/main-nav.component.html +++ b/admin-ui/src/app/core/components/main-nav/main-nav.component.html @@ -68,6 +68,13 @@ {{ 'nav.administrators' | translate }} +
  • + + {{ 'nav.channels' | translate }} + +
  • - {{ 'nav.tax-categories' | translate }} + {{ 'nav.tax-categories' | translate }} + +
  • +
  • + + {{ 'nav.tax-rates' | translate }}
  • diff --git a/admin-ui/src/app/data/definitions/adjustment-source-definitions.ts b/admin-ui/src/app/data/definitions/adjustment-source-definitions.ts deleted file mode 100644 index a435cc4a96..0000000000 --- a/admin-ui/src/app/data/definitions/adjustment-source-definitions.ts +++ /dev/null @@ -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} -`; diff --git a/admin-ui/src/app/data/definitions/product-definitions.ts b/admin-ui/src/app/data/definitions/product-definitions.ts index 0bd0fb7f45..5b0dfc108e 100644 --- a/admin-ui/src/app/data/definitions/product-definitions.ts +++ b/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 { diff --git a/admin-ui/src/app/data/definitions/promotion-definitions.ts b/admin-ui/src/app/data/definitions/promotion-definitions.ts new file mode 100644 index 0000000000..31c4577bc4 --- /dev/null +++ b/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} +`; diff --git a/admin-ui/src/app/data/definitions/settings-definitions.ts b/admin-ui/src/app/data/definitions/settings-definitions.ts index 7634b82dc9..58fbafb35a 100644 --- a/admin-ui/src/app/data/definitions/settings-definitions.ts +++ b/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} +`; diff --git a/admin-ui/src/app/data/providers/adjustment-source-data.service.ts b/admin-ui/src/app/data/providers/adjustment-source-data.service.ts deleted file mode 100644 index 94a2c25b81..0000000000 --- a/admin-ui/src/app/data/providers/adjustment-source-data.service.ts +++ /dev/null @@ -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( - 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( - GET_ADJUSTMENT_SOURCE_LIST, - { - type, - options: { - take, - skip, - }, - }, - ); - } - - private getAdjustmentSource(type: AdjustmentType, id: string) { - return this.baseDataService.query( - GET_ADJUSTMENT_SOURCE, - { - id, - }, - ); - } - - private createAdjustmentSource(input: CreateAdjustmentSourceInput) { - return this.baseDataService.mutate( - CREATE_ADJUSTMENT_SOURCE, - { - input, - }, - ); - } - - private updateAdjustmentSource(input: UpdateAdjustmentSourceInput) { - return this.baseDataService.mutate( - UPDATE_ADJUSTMENT_SOURCE, - { - input, - }, - ); - } -} diff --git a/admin-ui/src/app/data/providers/data.service.mock.ts b/admin-ui/src/app/data/providers/data.service.mock.ts index 8e3c9f24d7..e17f1eca68 100644 --- a/admin-ui/src/app/data/providers/data.service.mock.ts +++ b/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'), }; } diff --git a/admin-ui/src/app/data/providers/data.service.ts b/admin-ui/src/app/data/providers/data.service.ts index 03fa68af34..7870bd5ad0 100644 --- a/admin-ui/src/app/data/providers/data.service.ts +++ b/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); diff --git a/admin-ui/src/app/data/providers/product-data.service.ts b/admin-ui/src/app/data/providers/product-data.service.ts index 0560d0244e..489476f581 100644 --- a/admin-ui/src/app/data/providers/product-data.service.ts +++ b/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( UPDATE_PRODUCT_VARIANTS, diff --git a/admin-ui/src/app/data/providers/promotion-data.service.ts b/admin-ui/src/app/data/providers/promotion-data.service.ts new file mode 100644 index 0000000000..5677e4c541 --- /dev/null +++ b/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( + GET_PROMOTION_LIST, + { + options: { + take, + skip, + }, + }, + ); + } + + getPromotion(id: string) { + return this.baseDataService.query(GET_PROMOTION, { + id, + }); + } + + getAdjustmentOperations() { + return this.baseDataService.query(GET_ADJUSTMENT_OPERATIONS); + } + + createPromotion(input: CreatePromotionInput) { + return this.baseDataService.mutate( + CREATE_PROMOTION, + { + input, + }, + ); + } + + updatePromotion(input: UpdatePromotionInput) { + return this.baseDataService.mutate( + UPDATE_PROMOTION, + { + input, + }, + ); + } +} diff --git a/admin-ui/src/app/data/providers/settings-data.service.ts b/admin-ui/src/app/data/providers/settings-data.service.ts index 24f3abdf37..f32fb78055 100644 --- a/admin-ui/src/app/data/providers/settings-data.service.ts +++ b/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(GET_TAX_CATEGORIES); + } + + getTaxCategory(id: string) { + return this.baseDataService.query(GET_TAX_CATEGORY, { + id, + }); + } + + createTaxCategory(input: CreateTaxCategoryInput) { + return this.baseDataService.mutate( + CREATE_TAX_CATEGORY, + { + input, + }, + ); + } + + updateTaxCategory(input: UpdateTaxCategoryInput) { + return this.baseDataService.mutate( + UPDATE_TAX_CATEGORY, + { + input, + }, + ); + } + + getTaxRates(take: number = 10, skip: number = 0) { + return this.baseDataService.query(GET_TAX_RATE_LIST, { + options: { + take, + skip, + }, + }); + } + + getTaxRate(id: string) { + return this.baseDataService.query(GET_TAX_RATE, { + id, + }); + } + + createTaxRate(input: CreateTaxRateInput) { + return this.baseDataService.mutate(CREATE_TAX_RATE, { + input, + }); + } + + updateTaxRate(input: UpdateTaxRateInput) { + return this.baseDataService.mutate(UPDATE_TAX_RATE, { + input, + }); + } + + getChannels() { + return this.baseDataService.query(GET_CHANNELS); + } + + getChannel(id: string) { + return this.baseDataService.query(GET_CHANNEL, { + id, + }); + } + + createChannel(input: CreateChannelInput) { + return this.baseDataService.mutate(CREATE_CHANNEL, { + input, + }); + } + + updateChannel(input: UpdateChannelInput) { + return this.baseDataService.mutate(UPDATE_CHANNEL, { + input, + }); + } } diff --git a/admin-ui/src/app/marketing/components/promotion-detail/promotion-detail.component.ts b/admin-ui/src/app/marketing/components/promotion-detail/promotion-detail.component.ts index fb26b30fde..e70e9b9445 100644 --- a/admin-ui/src/app/marketing/components/promotion-detail/promotion-detail.component.ts +++ b/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 +export class PromotionDetailComponent extends BaseDetailComponent implements OnInit, OnDestroy { - promotion$: Observable; + promotion$: Observable; promotionForm: FormGroup; conditions: AdjustmentOperation[] = []; actions: AdjustmentOperation[] = []; @@ -55,12 +54,10 @@ export class PromotionDetailComponent extends BaseDetailComponent { - 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 { 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 { - 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 { this.addOperation('conditions', o); @@ -190,7 +186,10 @@ export class PromotionDetailComponent extends BaseDetailComponent { 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(), + })), }; }); } diff --git a/admin-ui/src/app/marketing/components/promotion-list/promotion-list.component.ts b/admin-ui/src/app/marketing/components/promotion-list/promotion-list.component.ts index 3d526f09b7..ca9c2e2d8c 100644 --- a/admin-ui/src/app/marketing/components/promotion-list/promotion-list.component.ts +++ b/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, ); } } diff --git a/admin-ui/src/app/marketing/marketing.routes.ts b/admin-ui/src/app/marketing/marketing.routes.ts index 62685af655..487da855cf 100644 --- a/admin-ui/src/app/marketing/marketing.routes.ts +++ b/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({ + return detailBreadcrumb({ entity: data.entity, id: params.id, breadcrumbKey: 'breadcrumb.promotions', diff --git a/admin-ui/src/app/marketing/providers/routing/promotion-resolver.ts b/admin-ui/src/app/marketing/providers/routing/promotion-resolver.ts index a7a78e024f..d67b2c7c35 100644 --- a/admin-ui/src/app/marketing/providers/routing/promotion-resolver.ts +++ b/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 { +export class PromotionResolver extends BaseEntityResolver { 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), ); } } diff --git a/admin-ui/src/app/settings/components/channel-detail/channel-detail.component.html b/admin-ui/src/app/settings/components/channel-detail/channel-detail.component.html new file mode 100644 index 0000000000..91d08471df --- /dev/null +++ b/admin-ui/src/app/settings/components/channel-detail/channel-detail.component.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + +
    +
    + + + + + + + + + +
    +
    diff --git a/admin-ui/src/app/settings/components/channel-detail/channel-detail.component.scss b/admin-ui/src/app/settings/components/channel-detail/channel-detail.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/admin-ui/src/app/settings/components/channel-detail/channel-detail.component.ts b/admin-ui/src/app/settings/components/channel-detail/channel-detail.component.ts new file mode 100644 index 0000000000..5c9ab4dd31 --- /dev/null +++ b/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 + implements OnInit, OnDestroy { + zones$: Observable; + 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 : '', + }); + } +} diff --git a/admin-ui/src/app/settings/components/channel-list/channel-list.component.html b/admin-ui/src/app/settings/components/channel-list/channel-list.component.html new file mode 100644 index 0000000000..4867a33322 --- /dev/null +++ b/admin-ui/src/app/settings/components/channel-list/channel-list.component.html @@ -0,0 +1,24 @@ + + + + + {{ 'settings.create-new-channel' | translate }} + + + + + + {{ 'common.ID' | translate }} + {{ 'common.code' | translate }} + + + {{ channel.id }} + {{ channel.code }} + + + + + + diff --git a/admin-ui/src/app/settings/components/channel-list/channel-list.component.scss b/admin-ui/src/app/settings/components/channel-list/channel-list.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/admin-ui/src/app/settings/components/channel-list/channel-list.component.ts b/admin-ui/src/app/settings/components/channel-list/channel-list.component.ts new file mode 100644 index 0000000000..59157b76e6 --- /dev/null +++ b/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; + + constructor(private dataService: DataService) { + this.channels$ = this.dataService.settings.getChannels().mapStream(data => data.channels); + } +} diff --git a/admin-ui/src/app/settings/components/tax-category-detail/tax-category-detail.component.html b/admin-ui/src/app/settings/components/tax-category-detail/tax-category-detail.component.html index b52407feda..2a6c5ef4b2 100644 --- a/admin-ui/src/app/settings/components/tax-category-detail/tax-category-detail.component.html +++ b/admin-ui/src/app/settings/components/tax-category-detail/tax-category-detail.component.html @@ -20,10 +20,5 @@ - - - - - diff --git a/admin-ui/src/app/settings/components/tax-category-detail/tax-category-detail.component.ts b/admin-ui/src/app/settings/components/tax-category-detail/tax-category-detail.component.ts index a083de44ea..3412454d6c 100644 --- a/admin-ui/src/app/settings/components/tax-category-detail/tax-category-detail.component.ts +++ b/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 +export class TaxCategoryDetailComponent extends BaseDetailComponent implements OnInit, OnDestroy { - taxCategory$: Observable; + taxCategory$: Observable; taxCategoryForm: FormGroup; private taxCondition: AdjustmentOperation; @@ -51,12 +50,6 @@ export class TaxCategoryDetailComponent extends BaseDetailComponent { - this.taxCondition = data.adjustmentOperations.conditions[0]; - this.taxAction = data.adjustmentOperations.actions[0]; - }); } ngOnDestroy() { @@ -72,15 +65,15 @@ export class TaxCategoryDetailComponent extends BaseDetailComponent { 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 { - 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 - + {{ 'common.ID' | translate }} {{ 'common.name' | translate }} - {{ 'settings.tax-rate' | translate }} {{ taxCategory.id }} {{ taxCategory.name }} - {{ taxCategory.actions[0].args[0].value }}% { - 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; + + constructor(private dataService: DataService) { + this.taxCategories$ = this.dataService.settings + .getTaxCategories() + .mapStream(data => data.taxCategories); } } diff --git a/admin-ui/src/app/settings/components/tax-rate-detail/tax-rate-detail.component.html b/admin-ui/src/app/settings/components/tax-rate-detail/tax-rate-detail.component.html new file mode 100644 index 0000000000..6928a1246d --- /dev/null +++ b/admin-ui/src/app/settings/components/tax-rate-detail/tax-rate-detail.component.html @@ -0,0 +1,49 @@ + + + + + + + + + + + + +
    +
    + + + + +
    + + +
    +
    + + + + + + + + + + + +
    +
    diff --git a/admin-ui/src/app/settings/components/tax-rate-detail/tax-rate-detail.component.scss b/admin-ui/src/app/settings/components/tax-rate-detail/tax-rate-detail.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/admin-ui/src/app/settings/components/tax-rate-detail/tax-rate-detail.component.ts b/admin-ui/src/app/settings/components/tax-rate-detail/tax-rate-detail.component.ts new file mode 100644 index 0000000000..99e2028152 --- /dev/null +++ b/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 + implements OnInit, OnDestroy { + taxCategories$: Observable; + zones$: Observable; + groups$: Observable; + 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 : '', + }); + } +} diff --git a/admin-ui/src/app/settings/components/tax-rate-list/tax-rate-list.component.html b/admin-ui/src/app/settings/components/tax-rate-list/tax-rate-list.component.html new file mode 100644 index 0000000000..9e82b496d3 --- /dev/null +++ b/admin-ui/src/app/settings/components/tax-rate-list/tax-rate-list.component.html @@ -0,0 +1,35 @@ + + + + + {{ 'settings.create-new-tax-rate' | translate }} + + + + + + {{ 'common.ID' | translate }} + {{ 'common.name' | translate }} + {{ 'settings.tax-category' | translate }} + {{ 'settings.zone' | translate }} + {{ 'settings.tax-rate' | translate }} + + + {{ taxRate.id }} + {{ taxRate.name }} + {{ taxRate.category.name }} + {{ taxRate.zone.name }} + {{ taxRate.value }}% + + + + + + diff --git a/admin-ui/src/app/settings/components/tax-rate-list/tax-rate-list.component.scss b/admin-ui/src/app/settings/components/tax-rate-list/tax-rate-list.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/admin-ui/src/app/settings/components/tax-rate-list/tax-rate-list.component.ts b/admin-ui/src/app/settings/components/tax-rate-list/tax-rate-list.component.ts new file mode 100644 index 0000000000..97de85b507 --- /dev/null +++ b/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 { + constructor(private dataService: DataService, router: Router, route: ActivatedRoute) { + super(router, route); + super.setQueryFn( + (...args: any[]) => this.dataService.settings.getTaxRates(...args), + data => data.taxRates, + ); + } +} diff --git a/admin-ui/src/app/settings/providers/routing/channel-resolver.ts b/admin-ui/src/app/settings/providers/routing/channel-resolver.ts new file mode 100644 index 0000000000..f394d6c039 --- /dev/null +++ b/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 { + 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), + ); + } +} diff --git a/admin-ui/src/app/settings/providers/routing/country-resolver.ts b/admin-ui/src/app/settings/providers/routing/country-resolver.ts index 2287dd206a..669f588e8d 100644 --- a/admin-ui/src/app/settings/providers/routing/country-resolver.ts +++ b/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'; diff --git a/admin-ui/src/app/settings/providers/routing/tax-category-resolver.ts b/admin-ui/src/app/settings/providers/routing/tax-category-resolver.ts index 685776a557..d58d041554 100644 --- a/admin-ui/src/app/settings/providers/routing/tax-category-resolver.ts +++ b/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 { +export class TaxCategoryResolver extends BaseEntityResolver { 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), ); } } diff --git a/admin-ui/src/app/settings/providers/routing/tax-rate-resolver.ts b/admin-ui/src/app/settings/providers/routing/tax-rate-resolver.ts new file mode 100644 index 0000000000..ba50f34209 --- /dev/null +++ b/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 { + 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), + ); + } +} diff --git a/admin-ui/src/app/settings/settings.module.ts b/admin-ui/src/app/settings/settings.module.ts index 550add78a2..b6b02fbe95 100644 --- a/admin-ui/src/app/settings/settings.module.ts +++ b/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 {} diff --git a/admin-ui/src/app/settings/settings.routes.ts b/admin-ui/src/app/settings/settings.routes.ts index 76a8e10995..dbb56b9437 100644 --- a/admin-ui/src/app/settings/settings.routes.ts +++ b/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({ + entity: data.entity, + id: params.id, + breadcrumbKey: 'breadcrumb.channels', + getName: channel => channel.code, + route: 'channels', + }); +} + export function roleBreadcrumb(data: any, params: any) { return detailBreadcrumb({ entity: data.entity, @@ -98,17 +142,27 @@ export function roleBreadcrumb(data: any, params: any) { } export function taxCategoryBreadcrumb(data: any, params: any) { - return detailBreadcrumb({ + return detailBreadcrumb({ 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({ + 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({ + return detailBreadcrumb({ entity: data.entity, id: params.id, breadcrumbKey: 'breadcrumb.countries', diff --git a/admin-ui/src/app/shared/components/adjustment-operation-input/adjustment-operation-input.component.html b/admin-ui/src/app/shared/components/adjustment-operation-input/adjustment-operation-input.component.html index 7acde4c4f5..d8dc758f90 100644 --- a/admin-ui/src/app/shared/components/adjustment-operation-input/adjustment-operation-input.component.html +++ b/admin-ui/src/app/shared/components/adjustment-operation-input/adjustment-operation-input.component.html @@ -10,7 +10,14 @@ - +
    + + +
    { - 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( - 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( - 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( - 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( - 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( - GET_ADJUSTMENT_SOURCE, - { - id: promoAdjustmentSource.id, - }, - ); - - expect(result.adjustmentSource!.name).toBe(promoAdjustmentSource.name); - }); - - it('adjustmentSources, type = promotion', async () => { - const result = await client.query( - 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( - 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( - 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( - GET_ADJUSTMENT_OPERATIONS, - { - type: AdjustmentType.PROMOTION, - }, - ); - - expect(result.adjustmentOperations).toMatchSnapshot(); - }); - - it('adjustmentOperations, type = tax', async () => { - const result = await client.query( - GET_ADJUSTMENT_OPERATIONS, - { - type: AdjustmentType.TAX, - }, - ); - - expect(result.adjustmentOperations).toMatchSnapshot(); - }); - - it('adjustmentOperations, type = shipping', async () => { - const result = await client.query( - 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 }]; - }, - }; -} diff --git a/server/e2e/config/test-config.ts b/server/e2e/config/test-config.ts index 06bbdb187f..f85e53a9dd 100644 --- a/server/e2e/config/test-config.ts +++ b/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', diff --git a/server/e2e/order.e2e-spec.ts b/server/e2e/order.e2e-spec.ts index 917993568b..487ccba9b0 100644 --- a/server/e2e/order.e2e-spec.ts +++ b/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 { diff --git a/server/e2e/product.e2e-spec.ts b/server/e2e/product.e2e-spec.ts index 20f7d6b718..8daef9be70 100644 --- a/server/e2e/product.e2e-spec.ts +++ b/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 () => { diff --git a/server/e2e/promotion.e2e-spec.ts b/server/e2e/promotion.e2e-spec.ts new file mode 100644 index 0000000000..fc41ad064a --- /dev/null +++ b/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( + 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( + 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(GET_PROMOTION, { + id: promotion.id, + }); + + expect(result.promotion!.name).toBe(promotion.name); + }); + + it('promotions', async () => { + const result = await client.query( + 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( + GET_ADJUSTMENT_OPERATIONS, + ); + + expect(result.adjustmentOperations).toMatchSnapshot(); + }); +}); + +function generateTestCondition(code: string): PromotionCondition { + return new PromotionCondition({ + code, + description: `description for ${code}`, + args: { arg: 'money' }, + check: (order, args) => true, + }); +} + +function generateTestAction(code: string): PromotionAction { + return new PromotionAction({ + code, + description: `description for ${code}`, + args: { percentage: 'percentage' }, + execute: (order, args) => { + return 42; + }, + }); +} diff --git a/server/mock-data/data-sources/countries.json b/server/mock-data/data-sources/countries.json index 5e3b28f5c5..7f0c3baca4 100644 --- a/server/mock-data/data-sources/countries.json +++ b/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", diff --git a/server/mock-data/mock-data.service.ts b/server/mock-data/mock-data.service.ts index bb48b1ebf1..bdca6e13e8 100644 --- a/server/mock-data/mock-data.service.ts +++ b/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 { - const channels: Channel[] = []; + async populateChannels(channelCodes: string[]): Promise { + const channels: Channel.Fragment[] = []; for (const code of channelCodes) { - const channel = await this.client.query(gql` - mutation { - createChannel(code: "${code}") { - id - code - token - } - } - `); + const channel = await this.client.query( + 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 { 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(CREATE_ZONE, { + const result = await this.client.query(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(GET_CHANNELS); + for (const channel of result.channels) { + await this.client.query(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 { @@ -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(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 { @@ -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 { 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 { const query = GENERATE_PRODUCT_VARIANTS; return this.client.query(query, { diff --git a/server/mock-data/populate.ts b/server/mock-data/populate.ts index 83a93fa787..689aa69ced 100644 --- a/server/mock-data/populate.ts +++ b/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(); diff --git a/server/src/api/api.module.ts b/server/src/api/api.module.ts index ab3f07a0a3..a4c77de07a 100644 --- a/server/src/api/api.module.ts +++ b/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, ]; diff --git a/server/src/api/common/request-context.service.ts b/server/src/api/common/request-context.service.ts index 0c0138d768..fa38ec0c30 100644 --- a/server/src/api/common/request-context.service.ts +++ b/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 { 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; } diff --git a/server/src/api/common/request-context.ts b/server/src/api/common/request-context.ts index 33615262a5..0aa5fa5008 100644 --- a/server/src/api/common/request-context.ts +++ b/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; } diff --git a/server/src/api/resolvers/adjustment-source.resolver.ts b/server/src/api/resolvers/adjustment-source.resolver.ts deleted file mode 100644 index 5f17a0646b..0000000000 --- a/server/src/api/resolvers/adjustment-source.resolver.ts +++ /dev/null @@ -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> { - 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 { - 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 { - return this.adjustmentSourceService.createAdjustmentSource(ctx, args.input); - } - - @Mutation() - @Allow(Permission.UpdateAdjustmentSource) - updateAdjustmentSource( - @Ctx() ctx: RequestContext, - @Args() args: UpdateAdjustmentSourceMutationArgs, - ): Promise { - return this.adjustmentSourceService.updateAdjustmentSource(ctx, args.input); - } -} diff --git a/server/src/api/resolvers/channel.resolver.ts b/server/src/api/resolvers/channel.resolver.ts index d754f1196f..9b2957ae91 100644 --- a/server/src/api/resolvers/channel.resolver.ts +++ b/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 { + return this.channelService.findAll(); + } + + @Query() + @Allow(Permission.SuperAdmin) + async channel(@Ctx() ctx: RequestContext, @Args() args: ChannelQueryArgs): Promise { + return this.channelService.findOne(args.id); + } + + @Mutation() + @Allow(Permission.SuperAdmin) + @Decode('defaultTaxZoneId', 'defaultShippingZoneId') + async createChannel(@Args() args: CreateChannelMutationArgs): Promise { + return this.channelService.create(args.input); + } + @Mutation() @Allow(Permission.SuperAdmin) - createChannel(@Args() args: CreateChannelMutationArgs): Promise { - return this.channelService.create(args.code); + @Decode('defaultTaxZoneId', 'defaultShippingZoneId') + async updateChannel(@Args() args: UpdateChannelMutationArgs): Promise { + return this.channelService.update(args.input); } } diff --git a/server/src/api/resolvers/promotion.resolver.ts b/server/src/api/resolvers/promotion.resolver.ts new file mode 100644 index 0000000000..9ebe6a1552 --- /dev/null +++ b/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> { + return this.promotionService.findAll(args.options || undefined); + } + + @Query() + @Allow(Permission.ReadSettings) + promotion(@Ctx() ctx: RequestContext, @Args() args: PromotionQueryArgs): Promise { + 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 { + return this.promotionService.createPromotion(ctx, args.input); + } + + @Mutation() + @Allow(Permission.UpdateSettings) + updatePromotion( + @Ctx() ctx: RequestContext, + @Args() args: UpdatePromotionMutationArgs, + ): Promise { + return this.promotionService.updatePromotion(ctx, args.input); + } +} diff --git a/server/src/api/resolvers/tax-category.resolver.ts b/server/src/api/resolvers/tax-category.resolver.ts new file mode 100644 index 0000000000..b3072156c3 --- /dev/null +++ b/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 { + return this.taxCategoryService.findAll(); + } + + @Query() + @Allow(Permission.ReadSettings) + async taxCategory( + @Ctx() ctx: RequestContext, + @Args() args: TaxCategoryQueryArgs, + ): Promise { + return this.taxCategoryService.findOne(args.id); + } + + @Mutation() + @Allow(Permission.CreateSettings) + async createTaxCategory(@Args() args: CreateTaxCategoryMutationArgs): Promise { + return this.taxCategoryService.create(args.input); + } + + @Mutation() + @Allow(Permission.UpdateSettings) + async updateTaxCategory(@Args() args: UpdateTaxCategoryMutationArgs): Promise { + return this.taxCategoryService.update(args.input); + } +} diff --git a/server/src/api/resolvers/tax-rate.resolver.ts b/server/src/api/resolvers/tax-rate.resolver.ts new file mode 100644 index 0000000000..515e58ea70 --- /dev/null +++ b/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> { + return this.taxRateService.findAll(args.options || undefined); + } + + @Query() + @Allow(Permission.ReadSettings) + async taxRate(@Ctx() ctx: RequestContext, @Args() args: TaxRateQueryArgs): Promise { + return this.taxRateService.findOne(args.id); + } + + @Mutation() + @Allow(Permission.CreateSettings) + @Decode('categoryId', 'zoneId', 'customerGroupId') + async createTaxRate(@Args() args: CreateTaxRateMutationArgs): Promise { + return this.taxRateService.create(args.input); + } + + @Mutation() + @Allow(Permission.UpdateSettings) + @Decode('categoryId', 'zoneId', 'customerGroupId') + async updateTaxRate(@Args() args: UpdateTaxRateMutationArgs): Promise { + return this.taxRateService.update(args.input); + } +} diff --git a/server/src/api/types/adjustment-source.api.graphql b/server/src/api/types/adjustment-source.api.graphql deleted file mode 100644 index 4593b4acf3..0000000000 --- a/server/src/api/types/adjustment-source.api.graphql +++ /dev/null @@ -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 -} diff --git a/server/src/api/types/channel.api.graphql b/server/src/api/types/channel.api.graphql index ca4a74aa92..e6da29c1aa 100644 --- a/server/src/api/types/channel.api.graphql +++ b/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! } diff --git a/server/src/api/types/promotion.api.graphql b/server/src/api/types/promotion.api.graphql new file mode 100644 index 0000000000..809974864c --- /dev/null +++ b/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 +} diff --git a/server/src/api/types/tax-category.api.graphql b/server/src/api/types/tax-category.api.graphql new file mode 100644 index 0000000000..dead1b1c16 --- /dev/null +++ b/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! +} diff --git a/server/src/api/types/tax-rate.api.graphql b/server/src/api/types/tax-rate.api.graphql new file mode 100644 index 0000000000..b8e6cfbcd6 --- /dev/null +++ b/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 +} diff --git a/server/src/common/calculated-decorator.ts b/server/src/common/calculated-decorator.ts new file mode 100644 index 0000000000..00fd9ebcd6 --- /dev/null +++ b/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]; + } + }; +} diff --git a/server/src/common/types/adjustment-source.ts b/server/src/common/types/adjustment-source.ts new file mode 100644 index 0000000000..1ff845975b --- /dev/null +++ b/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; +} diff --git a/server/src/common/types/common-types.graphql b/server/src/common/types/common-types.graphql index 07f0ace681..2aec47d7c0 100644 --- a/server/src/common/types/common-types.graphql +++ b/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! diff --git a/server/src/config/adjustment/adjustment-types.ts b/server/src/config/adjustment/adjustment-types.ts deleted file mode 100644 index f90efc6771..0000000000 --- a/server/src/config/adjustment/adjustment-types.ts +++ /dev/null @@ -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 = ( - 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; -} diff --git a/server/src/config/adjustment/default-adjustment-actions.ts b/server/src/config/adjustment/default-adjustment-actions.ts deleted file mode 100644 index 4ce490e211..0000000000 --- a/server/src/config/adjustment/default-adjustment-actions.ts +++ /dev/null @@ -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]; diff --git a/server/src/config/adjustment/default-adjustment-conditions.ts b/server/src/config/adjustment/default-adjustment-conditions.ts deleted file mode 100644 index cd5200ea95..0000000000 --- a/server/src/config/adjustment/default-adjustment-conditions.ts +++ /dev/null @@ -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]; diff --git a/server/src/config/adjustment/required-adjustment-actions.ts b/server/src/config/adjustment/required-adjustment-actions.ts deleted file mode 100644 index 923462a6c3..0000000000 --- a/server/src/config/adjustment/required-adjustment-actions.ts +++ /dev/null @@ -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 }%', -}; diff --git a/server/src/config/adjustment/required-adjustment-conditions.ts b/server/src/config/adjustment/required-adjustment-conditions.ts deleted file mode 100644 index 57f836013b..0000000000 --- a/server/src/config/adjustment/required-adjustment-conditions.ts +++ /dev/null @@ -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', -}; diff --git a/server/src/config/config.service.mock.ts b/server/src/config/config.service.mock.ts index 10f7281050..97b4ec0cc8 100644 --- a/server/src/config/config.service.mock.ts +++ b/server/src/config/config.service.mock.ts @@ -12,14 +12,15 @@ export class MockConfigService implements MockClass { port = 3000; cors = false; defaultLanguageCode: jest.Mock; + roundingStrategy: {}; entityIdStrategy = new MockIdStrategy(); assetNamingStrategy = {} as any; assetStorageStrategy = {} as any; assetPreviewStrategy = {} as any; uploadMaxFileSize = 1024; dbConnectionOptions = {}; - adjustmentConditions = []; - adjustmentActions = []; + promotionConditions = []; + promotionActions = []; customFields = {}; middleware = []; plugins = []; diff --git a/server/src/config/config.service.ts b/server/src/config/config.service.ts index b57e677da2..9c3367d428 100644 --- a/server/src/config/config.service.ts +++ b/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 { diff --git a/server/src/config/default-config.ts b/server/src/config/default-config.ts index 75c598a255..55abc278ae 100644 --- a/server/src/config/default-config.ts +++ b/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 = { 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 = { type: 'mysql', }, uploadMaxFileSize: 20971520, - adjustmentConditions: defaultAdjustmentConditions, - adjustmentActions: defaultAdjustmentActions, + promotionConditions: defaultPromotionConditions, + promotionActions: defaultPromotionActions, customFields: { Address: [], Customer: [], diff --git a/server/src/config/merge-config.ts b/server/src/config/merge-config.ts index bbd0a58607..ed5ff3f66f 100644 --- a/server/src/config/merge-config.ts +++ b/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(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; } diff --git a/server/src/config/promotion/default-promotion-actions.ts b/server/src/config/promotion/default-promotion-actions.ts new file mode 100644 index 0000000000..27a19afe98 --- /dev/null +++ b/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]; diff --git a/server/src/config/promotion/default-promotion-conditions.ts b/server/src/config/promotion/default-promotion-conditions.ts new file mode 100644 index 0000000000..2616419f3f --- /dev/null +++ b/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]; diff --git a/server/src/config/promotion/promotion-action.ts b/server/src/config/promotion/promotion-action.ts new file mode 100644 index 0000000000..53b72bdc6f --- /dev/null +++ b/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 = { [K in keyof T]: number }; +export type ExecutePromotionActionFn = ( + orderItem: OrderItem, + orderLine: OrderLine, + args: ArgumentValues, +) => number; + +export class PromotionAction { + readonly code: string; + readonly args: PromotionActionArgs; + readonly description: string; + private readonly executeFn: ExecutePromotionActionFn; + + constructor(config: { + args: T; + execute: ExecutePromotionActionFn; + 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 { + const output: ArgumentValues = {} as any; + for (const arg of args) { + if (arg.value != null) { + output[arg.name] = Number.parseInt(arg.value || '', 10); + } + } + return output; + } +} diff --git a/server/src/config/promotion/promotion-condition.ts b/server/src/config/promotion/promotion-condition.ts new file mode 100644 index 0000000000..deadd177bc --- /dev/null +++ b/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 = { + [K in keyof T]: T[K] extends 'int' | 'money' + ? number + : T[K] extends 'datetime' ? Date : T[K] extends 'boolean' ? boolean : string +}; + +export type CheckPromotionConditionFn = ( + order: Order, + args: ArgumentValues, +) => boolean; + +export class PromotionCondition { + readonly code: string; + readonly description: string; + readonly args: PromotionConditionArgs; + private readonly checkFn: CheckPromotionConditionFn; + + constructor(config: { args: T; check: CheckPromotionConditionFn; 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 { + const output: ArgumentValues = {} as any; + + for (const arg of args) { + if (arg.value != null) { + output[arg.name] = this.coerceValueToType(arg); + } + } + return output; + } + + private coerceValueToType(arg: AdjustmentArg): ArgumentValues[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; + } + } +} diff --git a/server/src/config/rounding-strategy/half-even-rounding-strategy.ts b/server/src/config/rounding-strategy/half-even-rounding-strategy.ts new file mode 100644 index 0000000000..1e6888fed3 --- /dev/null +++ b/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; + } +} diff --git a/server/src/config/rounding-strategy/half-up-rounding-strategy.ts b/server/src/config/rounding-strategy/half-up-rounding-strategy.ts new file mode 100644 index 0000000000..7a3e51f3b5 --- /dev/null +++ b/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); + } +} diff --git a/server/src/config/rounding-strategy/rounding-strategy.ts b/server/src/config/rounding-strategy/rounding-strategy.ts new file mode 100644 index 0000000000..13144f9287 --- /dev/null +++ b/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; +} diff --git a/server/src/config/vendure-config.ts b/server/src/config/vendure-config.ts index f53ade43e9..dd1cf268c7 100644 --- a/server/src/config/vendure-config.ts +++ b/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>; /** - * 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>; /** * Defines custom fields which can be used to extend the built-in entities. */ diff --git a/server/src/entity/adjustment-source/adjustment-source.entity.ts b/server/src/entity/adjustment-source/adjustment-source.entity.ts deleted file mode 100644 index c3caf8e5ec..0000000000 --- a/server/src/entity/adjustment-source/adjustment-source.entity.ts +++ /dev/null @@ -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) { - 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; -} diff --git a/server/src/entity/channel/channel.entity.ts b/server/src/entity/channel/channel.entity.ts index a40e9dd4cb..f41f5fe149 100644 --- a/server/src/entity/channel/channel.entity.ts +++ b/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() diff --git a/server/src/entity/channel/channel.graphql b/server/src/entity/channel/channel.graphql index 8fcfa4f35a..1c5cc6f4d4 100644 --- a/server/src/entity/channel/channel.graphql +++ b/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 } diff --git a/server/src/entity/entities.ts b/server/src/entity/entities.ts index 9ddfdb163d..f610f4d95c 100644 --- a/server/src/entity/entities.ts +++ b/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, }; diff --git a/server/src/entity/order-item/order-item.entity.ts b/server/src/entity/order-item/order-item.entity.ts index f728d67441..6a941ef838 100644 --- a/server/src/entity/order-item/order-item.entity.ts +++ b/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[]; } diff --git a/server/src/entity/order-item/order-item.graphql b/server/src/entity/order-item/order-item.graphql index 961ee02081..c0cbc1bccc 100644 --- a/server/src/entity/order-item/order-item.graphql +++ b/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! } diff --git a/server/src/entity/order-line/order-line.entity.ts b/server/src/entity/order-line/order-line.entity.ts new file mode 100644 index 0000000000..9e8a5f6401 --- /dev/null +++ b/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) { + 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); + } + }); + } +} diff --git a/server/src/entity/order-line/order-line.graphql b/server/src/entity/order-line/order-line.graphql new file mode 100644 index 0000000000..0d0467e662 --- /dev/null +++ b/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! +} diff --git a/server/src/entity/order/order.entity.ts b/server/src/entity/order/order.entity.ts index 4748514547..b494faad74 100644 --- a/server/src/entity/order/order.entity.ts +++ b/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[], + ); + } } diff --git a/server/src/entity/order/order.graphql b/server/src/entity/order/order.graphql index 087f5f281a..44db443bb5 100644 --- a/server/src/entity/order/order.graphql +++ b/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! } diff --git a/server/src/entity/product-variant/product-variant-price.entity.ts b/server/src/entity/product-variant/product-variant-price.entity.ts index cd22e41bd7..bda8e04f3e 100644 --- a/server/src/entity/product-variant/product-variant-price.entity.ts +++ b/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) diff --git a/server/src/entity/product-variant/product-variant.entity.ts b/server/src/entity/product-variant/product-variant.entity.ts index 3f7d452543..52fd7b1a91 100644 --- a/server/src/entity/product-variant/product-variant.entity.ts +++ b/server/src/entity/product-variant/product-variant.entity.ts @@ -1,4 +1,4 @@ -import { DeepPartial, HasCustomFields, ID } from 'shared/shared-types'; +import { DeepPartial, HasCustomFields } from 'shared/shared-types'; import { Column, Entity, JoinTable, ManyToMany, ManyToOne, OneToMany } from 'typeorm'; import { LocaleString, Translatable, Translation } from '../../common/types/locale-types'; @@ -7,6 +7,8 @@ import { CustomProductVariantFields } from '../custom-entity-fields'; import { FacetValue } from '../facet-value/facet-value.entity'; import { ProductOption } from '../product-option/product-option.entity'; import { Product } from '../product/product.entity'; +import { TaxCategory } from '../tax-category/tax-category.entity'; +import { TaxRate } from '../tax-rate/tax-rate.entity'; import { ProductVariantPrice } from './product-variant-price.entity'; import { ProductVariantTranslation } from './product-variant-translation.entity'; @@ -32,18 +34,17 @@ export class ProductVariant extends VendureEntity implements Translatable, HasCu price: number; /** - * A synthetic property which is populated with data from a ProductVariantPrice entity. + * Calculated at run-time */ - priceBeforeTax: number; + priceWithTax?: number; /** - * A synthetic property which is populated with data from a ProductVariantPrice entity. + * Calculated at run-time */ - taxCategory: { - id: ID; - name: string; - taxRate: number; - }; + taxRateApplied?: TaxRate; + + @ManyToOne(type => TaxCategory) + taxCategory: TaxCategory; @OneToMany(type => ProductVariantPrice, price => price.variant, { eager: true }) productVariantPrices: ProductVariantPrice[]; diff --git a/server/src/entity/product-variant/product-variant.graphql b/server/src/entity/product-variant/product-variant.graphql index 8eb9e5efb9..de30316bf3 100644 --- a/server/src/entity/product-variant/product-variant.graphql +++ b/server/src/entity/product-variant/product-variant.graphql @@ -5,9 +5,10 @@ type ProductVariant implements Node { languageCode: LanguageCode! sku: String! name: String! - priceBeforeTax: Int! price: Int! - taxCategory: ProductTaxCategory! + priceWithTax: Int! + taxRateApplied: TaxRate + taxCategory: TaxCategory! options: [ProductOption!]! facetValues: [FacetValue!]! translations: [ProductVariantTranslation!]! @@ -21,12 +22,6 @@ type ProductVariantTranslation { name: String! } -type ProductTaxCategory { - id: ID! - name: String! - taxRate: Float! -} - input ProductVariantTranslationInput { id: ID languageCode: LanguageCode! @@ -36,7 +31,6 @@ input ProductVariantTranslationInput { input CreateProductVariantInput { translations: [ProductVariantTranslationInput!]! sku: String! - priceBeforeTax: Int price: Int taxCategoryId: ID! optionCodes: [String!] @@ -47,6 +41,5 @@ input UpdateProductVariantInput { translations: [ProductVariantTranslationInput!] sku: String taxCategoryId: ID - priceBeforeTax: Int price: Int } diff --git a/server/src/entity/product-variant/product-variant.subscriber.ts b/server/src/entity/product-variant/product-variant.subscriber.ts index a72adf9829..314da6746b 100644 --- a/server/src/entity/product-variant/product-variant.subscriber.ts +++ b/server/src/entity/product-variant/product-variant.subscriber.ts @@ -1,8 +1,6 @@ -import { ID } from 'shared/shared-types'; -import { Connection, EntitySubscriberInterface, EventSubscriber, InsertEvent } from 'typeorm'; +import { EntitySubscriberInterface, EventSubscriber, InsertEvent } from 'typeorm'; import { I18nError } from '../../i18n/i18n-error'; -import { AdjustmentSource } from '../adjustment-source/adjustment-source.entity'; import { ProductVariantPrice } from './product-variant-price.entity'; import { ProductVariant } from './product-variant.entity'; @@ -22,11 +20,8 @@ export class ProductVariantSubscriber implements EntitySubscriberInterface { - const taxCategory = await connection.getRepository(AdjustmentSource).findOne(id); - if (!taxCategory) { - throw new I18nError(`error.tax-category-not-found`, { id }); - } - return taxCategory; - } } diff --git a/server/src/entity/promotion/promotion.entity.ts b/server/src/entity/promotion/promotion.entity.ts new file mode 100644 index 0000000000..1829eec787 --- /dev/null +++ b/server/src/entity/promotion/promotion.entity.ts @@ -0,0 +1,69 @@ +import { Adjustment, AdjustmentOperation, AdjustmentType } from 'shared/generated-types'; +import { DeepPartial } from 'shared/shared-types'; +import { Column, Entity, JoinTable, ManyToMany } from 'typeorm'; + +import { AdjustmentSource } from '../../common/types/adjustment-source'; +import { PromotionAction } from '../../config/promotion/promotion-action'; +import { PromotionCondition } from '../../config/promotion/promotion-condition'; +import { getConfig } from '../../config/vendure-config'; +import { Channel } from '../channel/channel.entity'; +import { OrderItem } from '../order-item/order-item.entity'; +import { OrderLine } from '../order-line/order-line.entity'; +import { Order } from '../order/order.entity'; + +@Entity() +export class Promotion extends AdjustmentSource { + type = AdjustmentType.PROMOTION; + private readonly allConditions: { [code: string]: PromotionCondition } = {}; + private readonly allActions: { [code: string]: PromotionAction } = {}; + private readonly round: (input: number) => number; + + constructor(input?: DeepPartial) { + super(input); + this.allConditions = getConfig().promotionConditions.reduce( + (hash, o) => ({ ...hash, [o.code]: o }), + {}, + ); + this.allActions = getConfig().promotionActions.reduce((hash, o) => ({ ...hash, [o.code]: o }), {}); + this.round = getConfig().roundingStrategy.round; + } + + @Column() name: string; + + @Column() enabled: boolean; + + @ManyToMany(type => Channel) + @JoinTable() + channels: Channel[]; + + @Column('simple-json') conditions: AdjustmentOperation[]; + + @Column('simple-json') actions: AdjustmentOperation[]; + + apply(orderItem: OrderItem, orderLine: OrderLine): Adjustment | undefined { + let amount = 0; + + for (const action of this.actions) { + const promotionAction = this.allActions[action.code]; + amount += this.round(promotionAction.execute(orderItem, orderLine, action.args)); + } + if (amount !== 0) { + return { + amount, + type: this.type, + description: this.name, + adjustmentSource: this.getSourceId(), + }; + } + } + + test(order: Order): boolean { + for (const condition of this.conditions) { + const promotionCondition = this.allConditions[condition.code]; + if (!promotionCondition || !promotionCondition.check(order, condition.args)) { + return false; + } + } + return true; + } +} diff --git a/server/src/entity/adjustment-source/adjustment-source.graphql b/server/src/entity/promotion/promotion.graphql similarity index 65% rename from server/src/entity/adjustment-source/adjustment-source.graphql rename to server/src/entity/promotion/promotion.graphql index e17c6b3030..9a3d97754c 100644 --- a/server/src/entity/adjustment-source/adjustment-source.graphql +++ b/server/src/entity/promotion/promotion.graphql @@ -1,25 +1,13 @@ -type AdjustmentSource implements Node { +type Promotion implements Node { id: ID! createdAt: DateTime! updatedAt: DateTime! name: String! - type: AdjustmentType! enabled: Boolean! conditions: [AdjustmentOperation!]! actions: [AdjustmentOperation!]! } -type Adjustment { - adjustmentSourceId: ID! - description: String! - amount: Int! -} - -enum AdjustmentType { - TAX - PROMOTION - SHIPPING -} type AdjustmentArg { name: String! @@ -28,26 +16,29 @@ type AdjustmentArg { } type AdjustmentOperation { - type: AdjustmentType! code: String! args: [AdjustmentArg!]! description: String! } +input AdjustmentOperationInputArg { + name: String! + value: String! +} + input AdjustmentOperationInput { code: String! - arguments: [String!]! + arguments: [AdjustmentOperationInputArg!]! } -input CreateAdjustmentSourceInput { +input CreatePromotionInput { name: String! - type: AdjustmentType! enabled: Boolean! conditions: [AdjustmentOperationInput!]! actions: [AdjustmentOperationInput!]! } -input UpdateAdjustmentSourceInput { +input UpdatePromotionInput { id: ID! name: String enabled: Boolean diff --git a/server/src/entity/role/role.graphql b/server/src/entity/role/role.graphql index 1017e04673..dfc62abfb5 100644 --- a/server/src/entity/role/role.graphql +++ b/server/src/entity/role/role.graphql @@ -1,5 +1,7 @@ type Role implements Node { id: ID! + createdAt: DateTime! + updatedAt: DateTime! code: String! description: String! permissions: [Permission!]! diff --git a/server/src/entity/subscribers.ts b/server/src/entity/subscribers.ts index 551adb213e..cc426776a6 100644 --- a/server/src/entity/subscribers.ts +++ b/server/src/entity/subscribers.ts @@ -1,8 +1,40 @@ +import { EntitySubscriberInterface, EventSubscriber } from 'typeorm'; + +import { CALCULATED_PROPERTIES } from '../common/calculated-decorator'; + import { ProductVariantSubscriber } from './product-variant/product-variant.subscriber'; +@EventSubscriber() +export class CalculatedPropertySubscriber implements EntitySubscriberInterface { + /** + * For any entity properties decorated with @Calculated(), this subscriber transfers + * the getter from the entity prototype to the entity instance, so that it can be + * correctly enumerated and serialized in the API response. + */ + afterLoad(event: any) { + const prototype = Object.getPrototypeOf(event); + if (prototype.hasOwnProperty(CALCULATED_PROPERTIES)) { + for (const property of prototype[CALCULATED_PROPERTIES]) { + const getterDescriptor = Object.getOwnPropertyDescriptor(prototype, property); + const getFn = getterDescriptor && getterDescriptor.get; + if (getFn) { + const boundGetFn = getFn.bind(event); + Object.defineProperties(event, { + [property]: { + get: () => boundGetFn(), + enumerable: true, + }, + }); + } + } + } + } +} + /** * A map of the core TypeORM Subscribers. */ export const coreSubscribersMap = { ProductVariantSubscriber, + CalculatedPropertySubscriber, }; diff --git a/server/src/entity/tax-category/tax-category.entity.ts b/server/src/entity/tax-category/tax-category.entity.ts new file mode 100644 index 0000000000..5311e6be96 --- /dev/null +++ b/server/src/entity/tax-category/tax-category.entity.ts @@ -0,0 +1,13 @@ +import { DeepPartial } from 'shared/shared-types'; +import { Column, Entity } from 'typeorm'; + +import { VendureEntity } from '../base/base.entity'; + +@Entity() +export class TaxCategory extends VendureEntity { + constructor(input?: DeepPartial) { + super(input); + } + + @Column() name: string; +} diff --git a/server/src/entity/tax-category/tax-category.graphql b/server/src/entity/tax-category/tax-category.graphql new file mode 100644 index 0000000000..fc43fa6b8a --- /dev/null +++ b/server/src/entity/tax-category/tax-category.graphql @@ -0,0 +1,16 @@ +type TaxCategory implements Node { + id: ID! + createdAt: DateTime! + updatedAt: DateTime! + name: String! +} + +input CreateTaxCategoryInput { + name: String! +} + + +input UpdateTaxCategoryInput { + id: ID! + name: String +} diff --git a/server/src/entity/tax-rate/tax-rate.entity.ts b/server/src/entity/tax-rate/tax-rate.entity.ts new file mode 100644 index 0000000000..621ed62a2e --- /dev/null +++ b/server/src/entity/tax-rate/tax-rate.entity.ts @@ -0,0 +1,56 @@ +import { Adjustment, AdjustmentType } from 'shared/generated-types'; +import { DeepPartial } from 'shared/shared-types'; +import { Column, Entity, ManyToOne } from 'typeorm'; + +import { AdjustmentSource } from '../../common/types/adjustment-source'; +import { idsAreEqual } from '../../common/utils'; +import { getConfig } from '../../config/vendure-config'; +import { CustomerGroup } from '../customer-group/customer-group.entity'; +import { TaxCategory } from '../tax-category/tax-category.entity'; +import { Zone } from '../zone/zone.entity'; + +@Entity() +export class TaxRate extends AdjustmentSource { + readonly type = AdjustmentType.TAX; + private readonly round: (input: number) => number; + + constructor(input?: DeepPartial) { + super(input); + this.round = getConfig().roundingStrategy.round; + } + + @Column() name: string; + + @Column() enabled: boolean; + + @Column() value: number; + + @ManyToOne(type => TaxCategory) + category: TaxCategory; + + @ManyToOne(type => Zone) + zone: Zone; + + @ManyToOne(type => CustomerGroup, { nullable: true }) + customerGroup?: CustomerGroup; + + /** + * Returns the tax applicable to the given price. + */ + getTax(price: number): number { + return this.round(price * (this.value / 100)); + } + + apply(price: number): Adjustment { + return { + type: this.type, + adjustmentSource: this.getSourceId(), + description: this.name, + amount: this.getTax(price), + }; + } + + test(zone: Zone, taxCategory: TaxCategory): boolean { + return idsAreEqual(taxCategory.id, this.category.id) && idsAreEqual(zone.id, this.zone.id); + } +} diff --git a/server/src/entity/tax-rate/tax-rate.graphql b/server/src/entity/tax-rate/tax-rate.graphql new file mode 100644 index 0000000000..937ebc009c --- /dev/null +++ b/server/src/entity/tax-rate/tax-rate.graphql @@ -0,0 +1,30 @@ +type TaxRate implements Node { + id: ID! + createdAt: DateTime! + updatedAt: DateTime! + name: String! + enabled: Boolean! + value: Int! + category: TaxCategory! + zone: Zone! + customerGroup: CustomerGroup +} + +input CreateTaxRateInput { + name: String! + enabled: Boolean! + value: Int! + categoryId: ID! + zoneId: ID! + customerGroupId: ID +} + +input UpdateTaxRateInput { + id: ID! + name: String + value: Int + enabled: Boolean + categoryId: ID + zoneId: ID + customerGroupId: ID +} diff --git a/server/src/i18n/messages/en.json b/server/src/i18n/messages/en.json index ad7a0e2c78..4a446b992e 100644 --- a/server/src/i18n/messages/en.json +++ b/server/src/i18n/messages/en.json @@ -1,10 +1,11 @@ { "error": { "cannot-modify-role": "The role '{ roleCode }' cannot be modified", + "channel-not-found": "No channel with the token \"{ token }\" exists", "entity-has-no-translation-in-language": "Translatable entity '{ entityName }' has not been translated into the requested language ({ languageCode })", "entity-with-id-not-found": "No { entityName } with the id '{ id }' could be found", "invalid-sort-field": "The sort field '{ fieldName }' is invalid. Valid fields are: { validFields }", - "order-does-not-contain-item-with-id": "This order does not contain an OrderItem with the id { id }", + "order-does-not-contain-line-with-id": "This order does not contain an OrderLine with the id { id }", "order-item-quantity-must-be-positive": "{ quantity } is not a valid quantity for an OrderItem" } } diff --git a/server/src/service/helpers/apply-adjustments.spec.ts b/server/src/service/helpers/apply-adjustments.spec.ts deleted file mode 100644 index 9aa2ffeac6..0000000000 --- a/server/src/service/helpers/apply-adjustments.spec.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { AdjustmentType } from 'shared/generated-types'; - -import { - AdjustmentActionDefinition, - AdjustmentConditionDefinition, -} from '../../config/adjustment/adjustment-types'; -import { taxAction } from '../../config/adjustment/required-adjustment-actions'; -import { taxCondition } from '../../config/adjustment/required-adjustment-conditions'; -import { AdjustmentSource } from '../../entity/adjustment-source/adjustment-source.entity'; -import { OrderItem } from '../../entity/order-item/order-item.entity'; -import { Order } from '../../entity/order/order.entity'; - -import { applyAdjustments, orderAdjustmentSources } from './apply-adjustments'; - -describe('orderAdjustmentSources()', () => { - it('orders sources correctly', () => { - const result = orderAdjustmentSources([ - { id: 1, type: AdjustmentType.PROMOTION } as any, - { id: 2, type: AdjustmentType.SHIPPING } as any, - { id: 3, type: AdjustmentType.TAX } as any, - { id: 4, type: AdjustmentType.PROMOTION } as any, - { id: 5, type: AdjustmentType.PROMOTION } as any, - { id: 6, type: AdjustmentType.TAX } as any, - { id: 7, type: AdjustmentType.SHIPPING } as any, - ]); - - expect(result.map(s => s.id)).toEqual([3, 6, 1, 4, 5, 2, 7]); - }); -}); - -describe('applyAdjustments()', () => { - const minOrderTotalCondition: AdjustmentConditionDefinition = { - code: 'min_order_total', - description: 'Order total is at least { minimum }', - args: [{ name: 'minimum', type: 'money' }], - type: AdjustmentType.PROMOTION, - predicate: (order, args) => { - return order.totalPrice >= args.minimum; - }, - }; - - const orderDiscountAction: AdjustmentActionDefinition = { - code: 'order_discount', - description: 'Discount order total by { percentage }%', - args: [{ name: 'percentage', type: 'percentage' }], - type: AdjustmentType.PROMOTION, - calculate: (order, args) => { - return [ - { - amount: -((order.totalPrice * args.percentage) / 100), - }, - ]; - }, - }; - - const promoSource1 = new AdjustmentSource({ - id: 'ps1', - name: 'Promo source 1', - type: AdjustmentType.PROMOTION, - conditions: [ - { - code: minOrderTotalCondition.code, - args: [ - { - type: 'money', - name: 'minimum', - value: '500', - }, - ], - }, - ], - actions: [ - { - code: orderDiscountAction.code, - args: [ - { - type: 'percentage', - name: 'percentage', - value: '10', - }, - ], - }, - ], - }); - - const standardTaxSource = AdjustmentSource.createTaxCategory(20, 'Standard Tax', 'ts1'); - const zeroTaxSource = AdjustmentSource.createTaxCategory(0, 'Zero Tax 2', 'ts2'); - - const conditions = [minOrderTotalCondition, taxCondition]; - const actions = [orderDiscountAction, taxAction]; - - it('applies a promo source to an order', () => { - const order = new Order({ - code: 'ABC', - items: [ - new OrderItem({ - id: 'oi1', - unitPrice: 300, - quantity: 2, - totalPriceBeforeAdjustment: 600, - }), - ], - totalPriceBeforeAdjustment: 600, - }); - - applyAdjustments(order, [promoSource1], conditions, actions); - - expect(order.adjustments).toEqual([ - { - adjustmentSourceId: promoSource1.id, - description: promoSource1.name, - amount: -60, - }, - ]); - expect(order.items[0].adjustments).toEqual([]); - expect(order.totalPrice).toBe(540); - }); - - it('applies a tax source to order items', () => { - const order = new Order({ - code: 'ABC', - items: [ - new OrderItem({ - id: 'oi1', - unitPrice: 300, - quantity: 2, - totalPriceBeforeAdjustment: 600, - taxCategoryId: standardTaxSource.id, - }), - new OrderItem({ - id: 'oi2', - unitPrice: 450, - quantity: 1, - totalPriceBeforeAdjustment: 450, - taxCategoryId: zeroTaxSource.id, - }), - ], - totalPriceBeforeAdjustment: 1050, - }); - - applyAdjustments(order, [standardTaxSource, zeroTaxSource], conditions, actions); - - expect(order.adjustments).toEqual([]); - expect(order.items[0].adjustments).toEqual([ - { - adjustmentSourceId: standardTaxSource.id, - description: standardTaxSource.name, - amount: 120, - }, - ]); - expect(order.items[0].totalPrice).toBe(720); - expect(order.items[1].adjustments).toEqual([ - { - adjustmentSourceId: zeroTaxSource.id, - description: zeroTaxSource.name, - amount: 0, - }, - ]); - expect(order.items[1].totalPrice).toBe(450); - - expect(order.totalPrice).toBe(1170); - }); - - it('evaluates promo conditions on items after tax is applied', () => { - const order = new Order({ - code: 'ABC', - items: [ - new OrderItem({ - id: 'oi1', - unitPrice: 240, - quantity: 2, - totalPriceBeforeAdjustment: 480, - taxCategoryId: standardTaxSource.id, - }), - ], - totalPriceBeforeAdjustment: 480, - }); - - applyAdjustments(order, [promoSource1, standardTaxSource, zeroTaxSource], conditions, actions); - - expect(order.items[0].adjustments).toEqual([ - { - adjustmentSourceId: standardTaxSource.id, - description: standardTaxSource.name, - amount: 96, - }, - ]); - expect(order.items[0].totalPrice).toBe(576); - expect(order.adjustments).toEqual([ - { - adjustmentSourceId: promoSource1.id, - description: promoSource1.name, - amount: -58, - }, - ]); - expect(order.totalPrice).toBe(518); - }); -}); diff --git a/server/src/service/helpers/apply-adjustments.ts b/server/src/service/helpers/apply-adjustments.ts deleted file mode 100644 index cdf2c14e2c..0000000000 --- a/server/src/service/helpers/apply-adjustments.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { AdjustmentArg, AdjustmentType } from 'shared/generated-types'; - -import { idsAreEqual } from '../../common/utils'; -import { - AdjustmentActionDefinition, - AdjustmentActionResult, - AdjustmentConditionDefinition, -} from '../../config/adjustment/adjustment-types'; -import { Adjustment, AdjustmentSource } from '../../entity/adjustment-source/adjustment-source.entity'; -import { Order } from '../../entity/order/order.entity'; - -/** - * Evaluates the provided AdjustmentSources against the order and applies those whose conditions are all - * passing. In doing so, the Order and OrderItems entities are mutated, with their adjustments arrays - * being populated, and their totalPrice values being set. - */ -export function applyAdjustments( - order: Order, - adjustmentSources: AdjustmentSource[], - conditions: AdjustmentConditionDefinition[], - actions: AdjustmentActionDefinition[], -) { - initializeOrder(order); - const orderedSources = orderAdjustmentSources(adjustmentSources); - for (const source of orderedSources) { - if (!checkSourceConditions(order, source, conditions)) { - continue; - } - const results = applyActionCalculations(order, source, actions); - - for (const result of results) { - if (result.orderItemId) { - const item = order.items.find(i => idsAreEqual(i.id, result.orderItemId)); - if (item) { - item.adjustments.push({ - adjustmentSourceId: source.id, - description: source.name, - amount: result.amount, - }); - item.totalPrice += result.amount; - } - } else { - order.adjustments.push({ - adjustmentSourceId: source.id, - description: source.name, - amount: result.amount, - }); - } - } - order.totalPrice = getTotalPriceOfItems(order) + getTotalAdjustmentAmount(order.adjustments); - } -} - -/** - * Adjustment sources should be applied in the following order: taxes, promotions, shipping. - * This function arranges the sources to conform to this order. - */ -export function orderAdjustmentSources(sources: AdjustmentSource[]): AdjustmentSource[] { - let output: AdjustmentSource[] = []; - [AdjustmentType.TAX, AdjustmentType.PROMOTION, AdjustmentType.SHIPPING].forEach(type => { - output = [...output, ...sources.filter(s => s.type === type)]; - }); - return output; -} - -/** - * Initialize the total prices or the Order and its OrderItems to equal - * the price before any adjustments are applied, and set the adjustments - * to be an empty array. - */ -function initializeOrder(order: Order) { - for (const item of order.items) { - item.totalPrice = item.totalPriceBeforeAdjustment; - item.adjustments = []; - } - order.totalPrice = getTotalPriceOfItems(order); - order.adjustments = []; -} - -function getTotalPriceOfItems(order: Order): number { - return order.items.reduce((total, item) => total + item.totalPrice, 0); -} - -function getTotalAdjustmentAmount(adjustments: Adjustment[]): number { - return adjustments.reduce((total, adjustment) => total + adjustment.amount, 0); -} - -function checkSourceConditions( - order: Order, - source: AdjustmentSource, - conditions: AdjustmentConditionDefinition[], -): boolean { - for (const condition of source.conditions) { - const conditionConfig = conditions.find(c => c.code === condition.code); - if (!conditionConfig) { - return false; - } - if (!conditionConfig.predicate(order, argsArrayToHash(condition.args))) { - return false; - } - } - return true; -} - -function applyActionCalculations( - order: Order, - source: AdjustmentSource, - actions: AdjustmentActionDefinition[], -): AdjustmentActionResult[] { - let results: AdjustmentActionResult[] = []; - for (const action of source.actions) { - const actionConfig = actions.find(a => a.code === action.code); - if (!actionConfig) { - continue; - } - const context = source.type === AdjustmentType.TAX ? { taxCategoryId: source.id } : {}; - const actionResults = actionConfig - .calculate(order, argsArrayToHash(action.args), context) - .map(result => { - // Do not allow fractions of pennies. - result.amount = Math.round(result.amount); - return result; - }); - results = [...results, ...actionResults]; - } - return results; -} - -function argsArrayToHash(args: AdjustmentArg[]): { [name: string]: string | number } { - return args.reduce( - (hash, arg) => ({ - ...hash, - [arg.name]: ['int', 'percentage', 'money'].includes(arg.type) - ? Number.parseInt(arg.value || '', 10) - : arg.value, - }), - {}, - ); -} diff --git a/server/src/service/helpers/get-entity-or-throw.ts b/server/src/service/helpers/get-entity-or-throw.ts new file mode 100644 index 0000000000..127f7c262c --- /dev/null +++ b/server/src/service/helpers/get-entity-or-throw.ts @@ -0,0 +1,23 @@ +import { ID, Type } from 'shared/shared-types'; +import { Connection } from 'typeorm'; + +import { VendureEntity } from '../../entity/base/base.entity'; +import { I18nError } from '../../i18n/i18n-error'; + +/** + * Attempts to find an entity of the given type and id, and throws an error if not found. + */ +export async function getEntityOrThrow( + connection: Connection, + entityType: Type, + id: ID, +): Promise { + const entity = await connection.getRepository(entityType).findOne(id); + if (!entity) { + throw new I18nError(`error.entity-with-id-not-found`, { + entityName: entityType.name, + id, + }); + } + return entity; +} diff --git a/server/src/service/helpers/update-translatable.ts b/server/src/service/helpers/update-translatable.ts index 86b9eb5068..256fcbe1d7 100644 --- a/server/src/service/helpers/update-translatable.ts +++ b/server/src/service/helpers/update-translatable.ts @@ -15,6 +15,7 @@ export function updateTranslatable( entityType: Type, translationType: Type>, translationUpdaterService: TranslationUpdaterService, + beforeSave?: (newEntity: T) => void, ) { return async function saveTranslatable( connection: Connection, @@ -34,6 +35,9 @@ export function updateTranslatable( diff, ); const updatedEntity = patchEntity(entity as any, omit(input, ['translations'])); - return connection.manager.save(entity, { data }); + if (typeof beforeSave === 'function') { + await beforeSave(entity); + } + return connection.manager.save(updatedEntity, { data }); }; } diff --git a/server/src/service/providers/adjustment-applicator.service.ts b/server/src/service/providers/adjustment-applicator.service.ts deleted file mode 100644 index 40fed8b23d..0000000000 --- a/server/src/service/providers/adjustment-applicator.service.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { InjectConnection } from '@nestjs/typeorm'; -import { Connection } from 'typeorm'; - -import { ConfigService } from '../../config/config.service'; -import { Order } from '../../entity/order/order.entity'; -import { applyAdjustments } from '../helpers/apply-adjustments'; - -import { AdjustmentSourceService } from './adjustment-source.service'; - -@Injectable() -export class AdjustmentApplicatorService { - constructor( - @InjectConnection() private connection: Connection, - private configService: ConfigService, - private adjustmentSourceService: AdjustmentSourceService, - ) {} - - /** - * Applies AdjustmentSources to an order, updating the adjustment arrays of the Order and - * its OrderItems and updating the prices based on the adjustment actions. - */ - async applyAdjustments(order: Order): Promise { - const sources = await this.adjustmentSourceService.getActiveAdjustmentSources(); - const { adjustmentConditions, adjustmentActions } = this.configService; - applyAdjustments(order, sources, adjustmentConditions, adjustmentActions); - await this.connection.manager.save(order.items); - return await this.connection.manager.save(order); - } -} diff --git a/server/src/service/providers/channel.service.ts b/server/src/service/providers/channel.service.ts index a42c0f2d6c..a3b0f611a8 100644 --- a/server/src/service/providers/channel.service.ts +++ b/server/src/service/providers/channel.service.ts @@ -1,14 +1,20 @@ import { Injectable } from '@nestjs/common'; import { InjectConnection } from '@nestjs/typeorm'; +import { CreateChannelInput, UpdateChannelInput } from 'shared/generated-types'; import { DEFAULT_CHANNEL_CODE } from 'shared/shared-constants'; +import { ID } from 'shared/shared-types'; import { Connection } from 'typeorm'; import { RequestContext } from '../../api/common/request-context'; import { DEFAULT_LANGUAGE_CODE } from '../../common/constants'; import { ChannelAware } from '../../common/types/common-types'; +import { assertFound } from '../../common/utils'; import { ConfigService } from '../../config/config.service'; import { Channel } from '../../entity/channel/channel.entity'; +import { Zone } from '../../entity/zone/zone.entity'; import { I18nError } from '../../i18n/i18n-error'; +import { getEntityOrThrow } from '../helpers/get-entity-or-throw'; +import { patchEntity } from '../helpers/patch-entity'; @Injectable() export class ChannelService { @@ -22,7 +28,7 @@ export class ChannelService { */ async initChannels() { await this.ensureDefaultChannelExists(); - this.allChannels = await this.findAll(); + await this.updateAllChannels(); } /** @@ -38,8 +44,12 @@ export class ChannelService { /** * Given a channel token, returns the corresponding Channel if it exists. */ - getChannelFromToken(token: string): Channel | undefined { - return this.allChannels.find(channel => channel.token === token); + getChannelFromToken(token: string): Channel { + const channel = this.allChannels.find(c => c.token === token); + if (!channel) { + throw new I18nError(`error.channel-not-found`, { token }); + } + return channel; } /** @@ -55,19 +65,62 @@ export class ChannelService { } findAll(): Promise { - return this.connection.getRepository(Channel).find(); + return this.connection + .getRepository(Channel) + .find({ relations: ['defaultShippingZone', 'defaultTaxZone'] }); } - async create(code: string): Promise { - const channel = new Channel({ - code, - defaultLanguageCode: DEFAULT_LANGUAGE_CODE, - }); + findOne(id: ID): Promise { + return this.connection + .getRepository(Channel) + .findOne(id, { relations: ['defaultShippingZone', 'defaultTaxZone'] }); + } + + async create(input: CreateChannelInput): Promise { + const channel = new Channel(input); + if (input.defaultTaxZoneId) { + channel.defaultTaxZone = await getEntityOrThrow(this.connection, Zone, input.defaultTaxZoneId); + } + if (input.defaultShippingZoneId) { + channel.defaultShippingZone = await getEntityOrThrow( + this.connection, + Zone, + input.defaultShippingZoneId, + ); + } const newChannel = await this.connection.getRepository(Channel).save(channel); - this.allChannels.push(channel); + await this.updateAllChannels(); return channel; } + async update(input: UpdateChannelInput): Promise { + const channel = await this.findOne(input.id); + if (!channel) { + throw new I18nError(`error.entity-with-id-not-found`, { + entityName: 'Channel', + id: input.id, + }); + } + const updatedChannel = patchEntity(channel, input); + if (input.defaultTaxZoneId) { + updatedChannel.defaultTaxZone = await getEntityOrThrow( + this.connection, + Zone, + input.defaultTaxZoneId, + ); + } + if (input.defaultShippingZoneId) { + updatedChannel.defaultShippingZone = await getEntityOrThrow( + this.connection, + Zone, + input.defaultShippingZoneId, + ); + } + await this.connection.getRepository(Channel).save(updatedChannel); + await this.updateAllChannels(); + return assertFound(this.findOne(channel.id)); + } + /** * There must always be a default Channel. If none yet exists, this method creates one. * Also ensures the default Channel token matches the defaultChannelToken config setting. @@ -92,4 +145,8 @@ export class ChannelService { await this.connection.manager.save(defaultChannel); } } + + private async updateAllChannels() { + this.allChannels = await this.findAll(); + } } diff --git a/server/src/service/providers/customer-group.service.ts b/server/src/service/providers/customer-group.service.ts index 4f25856c64..f3df43099c 100644 --- a/server/src/service/providers/customer-group.service.ts +++ b/server/src/service/providers/customer-group.service.ts @@ -13,7 +13,7 @@ import { Connection } from 'typeorm'; import { assertFound } from '../../common/utils'; import { CustomerGroup } from '../../entity/customer-group/customer-group.entity'; import { Customer } from '../../entity/customer/customer.entity'; -import { I18nError } from '../../i18n/i18n-error'; +import { getEntityOrThrow } from '../helpers/get-entity-or-throw'; import { patchEntity } from '../helpers/patch-entity'; @Injectable() @@ -38,7 +38,7 @@ export class CustomerGroupService { } async update(input: UpdateCustomerGroupInput): Promise { - const customerGroup = await this.getCustomerGroupOrThrow(input.id); + const customerGroup = await getEntityOrThrow(this.connection, CustomerGroup, input.id); const updatedCustomerGroup = patchEntity(customerGroup, input); await this.connection.getRepository(CustomerGroup).save(updatedCustomerGroup); return assertFound(this.findOne(customerGroup.id)); @@ -46,7 +46,7 @@ export class CustomerGroupService { async addCustomersToGroup(input: AddCustomersToGroupMutationArgs): Promise { const countries = await this.getCustomersFromIds(input.customerIds); - const customerGroup = await this.getCustomerGroupOrThrow(input.customerGroupId); + const customerGroup = await getEntityOrThrow(this.connection, CustomerGroup, input.customerGroupId); const customers = unique(customerGroup.customers.concat(countries), 'id'); customerGroup.customers = customers; await this.connection.getRepository(CustomerGroup).save(customerGroup); @@ -54,7 +54,7 @@ export class CustomerGroupService { } async removeCustomersFromGroup(input: RemoveCustomersFromGroupMutationArgs): Promise { - const customerGroup = await this.getCustomerGroupOrThrow(input.customerGroupId); + const customerGroup = await getEntityOrThrow(this.connection, CustomerGroup, input.customerGroupId); customerGroup.customers = customerGroup.customers.filter( customer => !input.customerIds.includes(customer.id as string), ); @@ -62,14 +62,6 @@ export class CustomerGroupService { return customerGroup; } - private async getCustomerGroupOrThrow(id: ID): Promise { - const customerGroup = await this.findOne(id); - if (!customerGroup) { - throw new I18nError(`error.entity-with-id-not-found`, { entityName: 'CustomerGroup', id }); - } - return customerGroup; - } - private getCustomersFromIds(ids: ID[]): Promise { return this.connection.getRepository(Customer).findByIds(ids); } diff --git a/server/src/service/providers/order.service.ts b/server/src/service/providers/order.service.ts index d91c6d700a..d862ed701d 100644 --- a/server/src/service/providers/order.service.ts +++ b/server/src/service/providers/order.service.ts @@ -1,30 +1,33 @@ import { InjectConnection } from '@nestjs/typeorm'; +import { AdjustmentType } from 'shared/generated-types'; import { ID, PaginatedList } from 'shared/shared-types'; import { Connection } from 'typeorm'; import { RequestContext } from '../../api/common/request-context'; import { generatePublicId } from '../../common/generate-public-id'; import { ListQueryOptions } from '../../common/types/common-types'; -import { assertFound, idsAreEqual } from '../../common/utils'; +import { idsAreEqual } from '../../common/utils'; import { OrderItem } from '../../entity/order-item/order-item.entity'; +import { OrderLine } from '../../entity/order-line/order-line.entity'; import { Order } from '../../entity/order/order.entity'; import { ProductVariant } from '../../entity/product-variant/product-variant.entity'; +import { Promotion } from '../../entity/promotion/promotion.entity'; +import { TaxRate } from '../../entity/tax-rate/tax-rate.entity'; +import { Zone } from '../../entity/zone/zone.entity'; import { I18nError } from '../../i18n/i18n-error'; import { buildListQuery } from '../helpers/build-list-query'; import { translateDeep } from '../helpers/translate-entity'; -import { AdjustmentApplicatorService } from './adjustment-applicator.service'; import { ProductVariantService } from './product-variant.service'; export class OrderService { constructor( @InjectConnection() private connection: Connection, private productVariantService: ProductVariantService, - private adjustmentApplicatorService: AdjustmentApplicatorService, ) {} findAll(ctx: RequestContext, options?: ListQueryOptions): Promise> { - return buildListQuery(this.connection, Order, options, ['items', 'items.productVariant', 'customer']) + return buildListQuery(this.connection, Order, options, ['lines', 'lines.productVariant', 'customer']) .getManyAndCount() .then(([items, totalItems]) => { return { @@ -36,10 +39,16 @@ export class OrderService { async findOne(ctx: RequestContext, orderId: ID): Promise { const order = await this.connection.getRepository(Order).findOne(orderId, { - relations: ['items', 'items.productVariant', 'items.featuredAsset'], + relations: [ + 'lines', + 'lines.productVariant', + 'lines.featuredAsset', + 'lines.items', + 'lines.taxCategory', + ], }); if (order) { - order.items.forEach(item => { + order.lines.forEach(item => { item.productVariant = translateDeep(item.productVariant, ctx.languageCode); }); return order; @@ -49,10 +58,9 @@ export class OrderService { create(): Promise { const newOrder = new Order({ code: generatePublicId(), - items: [], - adjustments: [], - totalPriceBeforeAdjustment: 0, + lines: [], totalPrice: 0, + totalPriceBeforeTax: 0, }); return this.connection.getRepository(Order).save(newOrder); } @@ -66,50 +74,58 @@ export class OrderService { this.assertQuantityIsPositive(quantity); const order = await this.getOrderOrThrow(ctx, orderId); const productVariant = await this.getProductVariantOrThrow(ctx, productVariantId); - const existingItem = order.items.find(item => idsAreEqual(item.productVariant.id, productVariantId)); + let orderLine = order.lines.find(line => idsAreEqual(line.productVariant.id, productVariantId)); - if (existingItem) { - return this.adjustItemQuantity(ctx, orderId, existingItem.id, existingItem.quantity + quantity); + if (!orderLine) { + const newLine = new OrderLine({ + productVariant, + taxCategory: productVariant.taxCategory, + featuredAsset: productVariant.product.featuredAsset, + unitPrice: productVariant.price, + }); + orderLine = await this.connection.getRepository(OrderLine).save(newLine); + order.lines.push(orderLine); + await this.connection.getRepository(Order).save(order); } - const orderItem = new OrderItem({ - quantity, - productVariant, - taxCategoryId: productVariant.taxCategory.id, - featuredAsset: productVariant.product.featuredAsset, - unitPrice: productVariant.price, - unitPriceBeforeTax: productVariant.priceBeforeTax, - totalPriceBeforeAdjustment: productVariant.priceBeforeTax * quantity, - totalPrice: productVariant.priceBeforeTax * quantity, - adjustments: [], - }); - const newOrderItem = await this.connection.getRepository(OrderItem).save(orderItem); - order.items.push(newOrderItem); - await this.adjustmentApplicatorService.applyAdjustments(order); - return assertFound(this.findOne(ctx, order.id)); + return this.adjustItemQuantity(ctx, orderId, orderLine.id, orderLine.quantity + quantity); } async adjustItemQuantity( ctx: RequestContext, orderId: ID, - orderItemId: ID, + orderLineId: ID, quantity: number, ): Promise { this.assertQuantityIsPositive(quantity); const order = await this.getOrderOrThrow(ctx, orderId); - const orderItem = this.getOrderItemOrThrow(order, orderItemId); - orderItem.quantity = quantity; - orderItem.totalPriceBeforeAdjustment = orderItem.unitPrice * orderItem.quantity; - await this.connection.getRepository(OrderItem).save(orderItem); - await this.adjustmentApplicatorService.applyAdjustments(order); - return assertFound(this.findOne(ctx, order.id)); + const orderLine = this.getOrderLineOrThrow(order, orderLineId); + const currentQuantity = orderLine.quantity; + if (currentQuantity < quantity) { + if (!orderLine.items) { + orderLine.items = []; + } + for (let i = currentQuantity; i < quantity; i++) { + const orderItem = await this.connection.getRepository(OrderItem).save( + new OrderItem({ + pendingAdjustments: [], + }), + ); + orderLine.items.push(orderItem); + } + } else if (quantity < currentQuantity) { + orderLine.items = orderLine.items.slice(0, quantity); + } + await this.connection.getRepository(OrderLine).save(orderLine); + return this.applyAdjustments(ctx, order); } - async removeItemFromOrder(ctx: RequestContext, orderId: ID, orderItemId: ID): Promise { + async removeItemFromOrder(ctx: RequestContext, orderId: ID, orderLineId: ID): Promise { const order = await this.getOrderOrThrow(ctx, orderId); - const orderItem = this.getOrderItemOrThrow(order, orderItemId); - order.items = order.items.filter(item => !idsAreEqual(item.id, orderItemId)); - await this.adjustmentApplicatorService.applyAdjustments(order); - return assertFound(this.findOne(ctx, order.id)); + const orderLine = this.getOrderLineOrThrow(order, orderLineId); + order.lines = order.lines.filter(line => !idsAreEqual(line.id, orderLineId)); + const updatedOrder = await this.applyAdjustments(ctx, order); + await this.connection.getRepository(OrderLine).remove(orderLine); + return updatedOrder; } private async getOrderOrThrow(ctx: RequestContext, orderId: ID): Promise { @@ -134,10 +150,10 @@ export class OrderService { return productVariant; } - private getOrderItemOrThrow(order: Order, orderItemId: ID): OrderItem { - const orderItem = order.items.find(item => idsAreEqual(item.id, orderItemId)); + private getOrderLineOrThrow(order: Order, orderLineId: ID): OrderLine { + const orderItem = order.lines.find(line => idsAreEqual(line.id, orderLineId)); if (!orderItem) { - throw new I18nError(`error.order-does-not-contain-item-with-id`, { id: orderItemId }); + throw new I18nError(`error.order-does-not-contain-line-with-id`, { id: orderLineId }); } return orderItem; } @@ -150,4 +166,87 @@ export class OrderService { throw new I18nError(`error.order-item-quantity-must-be-positive`, { quantity }); } } + + // TODO: Refactor the mail calculation logic out into a more testable service. + private async applyAdjustments(ctx: RequestContext, order: Order): Promise { + const activeZone = ctx.channel.defaultTaxZone; + const taxRates = await this.connection.getRepository(TaxRate).find({ + where: { + enabled: true, + zone: activeZone, + }, + relations: ['category', 'zone', 'customerGroup'], + }); + const promotions = await this.connection.getRepository(Promotion).find({ where: { enabled: true } }); + + order.clearAdjustments(); + // First apply taxes to the non-discounted prices + this.applyTaxes(order, taxRates, activeZone); + // Then test and apply promotions + this.applyPromotions(order, promotions); + // Finally, re-calculate taxes because the promotions may have + // altered the unit prices, which in turn will alter the tax payable. + this.applyTaxes(order, taxRates, activeZone); + + await this.connection.getRepository(Order).save(order); + await this.connection.getRepository(OrderItem).save(order.getOrderItems()); + return order; + } + + /** + * Applies the correct TaxRate to each OrderItem in the order. + */ + private applyTaxes(order: Order, taxRates: TaxRate[], activeZone: Zone) { + for (const line of order.lines) { + const applicableTaxRate = taxRates.find(taxRate => taxRate.test(activeZone, line.taxCategory)); + + line.clearAdjustments(AdjustmentType.TAX); + + for (const item of line.items) { + if (applicableTaxRate) { + item.pendingAdjustments = item.pendingAdjustments.concat( + applicableTaxRate.apply(line.unitPriceWithPromotions), + ); + } + } + this.calculateOrderTotals(order); + } + } + + /** + * Applies any eligible promotions to each OrderItem in the order. + */ + private applyPromotions(order: Order, promotions: Promotion[]) { + for (const line of order.lines) { + const applicablePromotions = promotions.filter(p => p.test(order)); + + line.clearAdjustments(AdjustmentType.PROMOTION); + + for (const item of line.items) { + if (applicablePromotions) { + for (const promotion of applicablePromotions) { + const adjustment = promotion.apply(item, line); + if (adjustment) { + item.pendingAdjustments = item.pendingAdjustments.concat(adjustment); + } + } + } + } + this.calculateOrderTotals(order); + } + } + + private calculateOrderTotals(order: Order) { + let totalPrice = 0; + let totalTax = 0; + + for (const line of order.lines) { + totalPrice += line.totalPrice; + totalTax += line.unitTax * line.quantity; + } + const totalPriceBeforeTax = totalPrice - totalTax; + + order.totalPriceBeforeTax = totalPriceBeforeTax; + order.totalPrice = totalPrice; + } } diff --git a/server/src/service/providers/product-variant.service.ts b/server/src/service/providers/product-variant.service.ts index 6c2122266d..fa302276bd 100644 --- a/server/src/service/providers/product-variant.service.ts +++ b/server/src/service/providers/product-variant.service.ts @@ -14,30 +14,36 @@ import { ProductOption } from '../../entity/product-option/product-option.entity import { ProductVariantTranslation } from '../../entity/product-variant/product-variant-translation.entity'; import { ProductVariant } from '../../entity/product-variant/product-variant.entity'; import { Product } from '../../entity/product/product.entity'; +import { Zone } from '../../entity/zone/zone.entity'; import { I18nError } from '../../i18n/i18n-error'; import { createTranslatable } from '../helpers/create-translatable'; import { translateDeep } from '../helpers/translate-entity'; import { TranslationUpdaterService } from '../helpers/translation-updater.service'; import { updateTranslatable } from '../helpers/update-translatable'; -import { AdjustmentSourceService } from './adjustment-source.service'; +import { TaxCategoryService } from './tax-category.service'; +import { TaxRateService } from './tax-rate.service'; @Injectable() export class ProductVariantService { constructor( @InjectConnection() private connection: Connection, + private taxCategoryService: TaxCategoryService, + private taxRateService: TaxRateService, private translationUpdaterService: TranslationUpdaterService, - private adjustmentSourceService: AdjustmentSourceService, ) {} findOne(ctx: RequestContext, productVariantId: ID): Promise | undefined> { - const relations = ['product', 'product.featuredAsset']; + const relations = ['product', 'product.featuredAsset', 'taxCategory']; return this.connection .getRepository(ProductVariant) .findOne(productVariantId, { relations }) .then(result => { if (result) { - return translateDeep(this.applyChannelPrice(result, ctx.channelId), ctx.languageCode); + return translateDeep( + this.applyChannelPriceAndTax(result, ctx.channelId, ctx.channel.defaultTaxZone), + ctx.languageCode, + ); } }); } @@ -55,6 +61,7 @@ export class ProductVariantService { variant.options = selectedOptions; } variant.product = product; + variant.taxCategory = { id: input.taxCategoryId } as any; }); return await save(this.connection, input, { channelId: ctx.channelId, @@ -67,17 +74,26 @@ export class ProductVariantService { ProductVariant, ProductVariantTranslation, this.translationUpdaterService, + async updatedVariant => { + if (input.taxCategoryId) { + const taxCategory = await this.taxCategoryService.findOne(input.taxCategoryId); + if (taxCategory) { + updatedVariant.taxCategory = taxCategory; + } + } + }, ); await save(this.connection, input, { channelId: ctx.channelId, taxCategoryId: input.taxCategoryId }); const variant = await assertFound( this.connection.manager.getRepository(ProductVariant).findOne(input.id, { - relations: ['options', 'facetValues'], + relations: ['options', 'facetValues', 'taxCategory'], }), ); - return translateDeep(this.applyChannelPrice(variant, ctx.channelId), DEFAULT_LANGUAGE_CODE, [ - 'options', - 'facetValues', - ]); + return translateDeep( + this.applyChannelPriceAndTax(variant, ctx.channelId, ctx.channel.defaultTaxZone), + DEFAULT_LANGUAGE_CODE, + ['options', 'facetValues'], + ); } async generateVariantsForProduct( @@ -101,9 +117,8 @@ export class ProductVariantService { ? generateAllCombinations(product.optionGroups.map(g => g.options)) : [[]]; - const taxCategoryId = - defaultTaxCategoryId || - (await this.adjustmentSourceService.getDefaultTaxCategory()).id.toString(); + // TODO: how to handle default tax category? + const taxCategoryId = defaultTaxCategoryId || '1'; const variants: ProductVariant[] = []; for (const options of optionCombinations) { @@ -132,7 +147,7 @@ export class ProductVariantService { facetValues: FacetValue[], ): Promise>> { const variants = await this.connection.getRepository(ProductVariant).findByIds(productVariantIds, { - relations: ['options', 'facetValues'], + relations: ['options', 'facetValues', 'taxCategory'], }); const notFoundIds = productVariantIds.filter(id => !variants.find(v => idsAreEqual(v.id, id))); @@ -152,28 +167,33 @@ export class ProductVariantService { } return variants.map(v => - translateDeep(this.applyChannelPrice(v, ctx.channelId), DEFAULT_LANGUAGE_CODE, [ - 'options', - 'facetValues', - ]), + translateDeep( + this.applyChannelPriceAndTax(v, ctx.channelId, ctx.channel.defaultTaxZone), + DEFAULT_LANGUAGE_CODE, + ['options', 'facetValues'], + ), ); } /** * Populates the `price` field with the price for the specified channel. */ - applyChannelPrice(variant: ProductVariant, channelId: ID): ProductVariant { + applyChannelPriceAndTax(variant: ProductVariant, channelId: ID, taxZone: Zone): ProductVariant { const channelPrice = variant.productVariantPrices.find(p => idsAreEqual(p.channelId, channelId)); if (!channelPrice) { throw new I18nError(`error.no-price-found-for-channel`); } variant.price = channelPrice.price; - variant.priceBeforeTax = channelPrice.priceBeforeTax; - variant.taxCategory = { - id: channelPrice.taxCategory.id, - name: channelPrice.taxCategory.name, - taxRate: channelPrice.taxCategory.getTaxCategoryRate() || 0, - }; + + const applicableTaxRate = this.taxRateService + .getActiveTaxRates() + .find(r => r.test(taxZone, variant.taxCategory)); + if (applicableTaxRate) { + variant.priceWithTax = variant.price + applicableTaxRate.getTax(variant.price); + variant.taxRateApplied = applicableTaxRate; + } else { + variant.priceWithTax = variant.price; + } return variant; } diff --git a/server/src/service/providers/product.service.ts b/server/src/service/providers/product.service.ts index 7ca0ab2ce4..26b87fc54a 100644 --- a/server/src/service/providers/product.service.ts +++ b/server/src/service/providers/product.service.ts @@ -7,7 +7,7 @@ import { Connection } from 'typeorm'; import { RequestContext } from '../../api/common/request-context'; import { ListQueryOptions } from '../../common/types/common-types'; import { Translated } from '../../common/types/locale-types'; -import { assertFound, idsAreEqual } from '../../common/utils'; +import { assertFound } from '../../common/utils'; import { ProductOptionGroup } from '../../entity/product-option-group/product-option-group.entity'; import { ProductTranslation } from '../../entity/product/product-translation.entity'; import { Product } from '../../entity/product/product.entity'; @@ -21,6 +21,7 @@ import { updateTranslatable } from '../helpers/update-translatable'; import { AssetService } from './asset.service'; import { ChannelService } from './channel.service'; import { ProductVariantService } from './product-variant.service'; +import { TaxRateService } from './tax-rate.service'; @Injectable() export class ProductService { @@ -30,6 +31,7 @@ export class ProductService { private channelService: ChannelService, private assetService: AssetService, private productVariantService: ProductVariantService, + private taxRateService: TaxRateService, ) {} findAll( @@ -43,12 +45,13 @@ export class ProductService { 'optionGroups', 'variants.options', 'variants.facetValues', + 'variants.taxCategory', 'channels', ]; return buildListQuery(this.connection, Product, options, relations, ctx.channelId) .getManyAndCount() - .then(([products, totalItems]) => { + .then(async ([products, totalItems]) => { const items = products .map(product => translateDeep(product, ctx.languageCode, [ @@ -58,7 +61,7 @@ export class ProductService { ['variants', 'facetValues'], ]), ) - .map(product => this.applyChannelPriceToVariants(product, ctx)); + .map(product => this.applyPriceAndTaxToVariants(product, ctx)); return { items, totalItems, @@ -74,6 +77,7 @@ export class ProductService { 'optionGroups', 'variants.options', 'variants.facetValues', + 'variants.taxCategory', ]; const product = await this.connection.manager.findOne(Product, productId, { relations }); if (!product) { @@ -85,7 +89,7 @@ export class ProductService { ['variants', 'options'], ['variants', 'facetValues'], ]); - return this.applyChannelPriceToVariants(translated, ctx); + return this.applyPriceAndTaxToVariants(translated, ctx); } async create(ctx: RequestContext, input: CreateProductInput): Promise> { @@ -156,10 +160,20 @@ export class ProductService { } } - private applyChannelPriceToVariants(product: T, ctx: RequestContext): T { - product.variants = product.variants.map(v => - this.productVariantService.applyChannelPrice(v, ctx.channelId), - ); + /** + * The price of a ProductVariant depends on the current channel and the priceWithTax further + * depends on the currently-active zone and applicable TaxRates. + * This method uses the RequestContext to determine these values and apply them to each + * ProductVariant of the given Product. + */ + private applyPriceAndTaxToVariants(product: T, ctx: RequestContext): T { + product.variants = product.variants.map(variant => { + return this.productVariantService.applyChannelPriceAndTax( + variant, + ctx.channelId, + ctx.channel.defaultTaxZone, + ); + }); return product; } diff --git a/server/src/service/providers/adjustment-source.service.ts b/server/src/service/providers/promotion.service.ts similarity index 57% rename from server/src/service/providers/adjustment-source.service.ts rename to server/src/service/providers/promotion.service.ts index 14dc0048db..5df7715989 100644 --- a/server/src/service/providers/adjustment-source.service.ts +++ b/server/src/service/providers/promotion.service.ts @@ -3,9 +3,8 @@ import { InjectConnection } from '@nestjs/typeorm'; import { AdjustmentOperation, AdjustmentOperationInput, - AdjustmentType, - CreateAdjustmentSourceInput, - UpdateAdjustmentSourceInput, + CreatePromotionInput, + UpdatePromotionInput, } from 'shared/generated-types'; import { omit } from 'shared/omit'; import { ID, PaginatedList } from 'shared/shared-types'; @@ -14,12 +13,10 @@ import { Connection } from 'typeorm'; import { RequestContext } from '../../api/common/request-context'; import { ListQueryOptions } from '../../common/types/common-types'; import { assertFound } from '../../common/utils'; -import { - AdjustmentActionDefinition, - AdjustmentConditionDefinition, -} from '../../config/adjustment/adjustment-types'; import { ConfigService } from '../../config/config.service'; -import { AdjustmentSource } from '../../entity/adjustment-source/adjustment-source.entity'; +import { PromotionAction } from '../../config/promotion/promotion-action'; +import { PromotionCondition } from '../../config/promotion/promotion-condition'; +import { Promotion } from '../../entity/promotion/promotion.entity'; import { I18nError } from '../../i18n/i18n-error'; import { buildListQuery } from '../helpers/build-list-query'; import { patchEntity } from '../helpers/patch-entity'; @@ -27,27 +24,27 @@ import { patchEntity } from '../helpers/patch-entity'; import { ChannelService } from './channel.service'; @Injectable() -export class AdjustmentSourceService { - availableConditions: AdjustmentConditionDefinition[] = []; - availableActions: AdjustmentActionDefinition[] = []; +export class PromotionService { + availableConditions: PromotionCondition[] = []; + availableActions: PromotionAction[] = []; /** * All active AdjustmentSources are cached in memory becuase they are needed * every time an order is changed, which will happen often. Caching them means * a DB call is not required newly each time. */ - private activeSources: AdjustmentSource[] = []; + private activePromotions: Promotion[] = []; constructor( @InjectConnection() private connection: Connection, private configService: ConfigService, private channelService: ChannelService, ) { - this.availableConditions = this.configService.adjustmentConditions; - this.availableActions = this.configService.adjustmentActions; + this.availableConditions = this.configService.promotionConditions; + this.availableActions = this.configService.promotionActions; } - findAll(options?: ListQueryOptions): Promise> { - return buildListQuery(this.connection, AdjustmentSource, options) + findAll(options?: ListQueryOptions): Promise> { + return buildListQuery(this.connection, Promotion, options) .getManyAndCount() .then(([items, totalItems]) => ({ items, @@ -55,69 +52,55 @@ export class AdjustmentSourceService { })); } - async findOne(adjustmentSourceId: ID): Promise { - return this.connection.manager.findOne(AdjustmentSource, adjustmentSourceId, { - relations: [], - }); + async findOne(adjustmentSourceId: ID): Promise { + return this.connection.manager.findOne(Promotion, adjustmentSourceId, {}); } /** * Returns all available AdjustmentOperations. */ - getAdjustmentOperations( - type: AdjustmentType, - ): { - conditions: AdjustmentConditionDefinition[]; - actions: AdjustmentActionDefinition[]; + getAdjustmentOperations(): { + conditions: AdjustmentOperation[]; + actions: AdjustmentOperation[]; } { + const toAdjustmentOperation = (source: PromotionCondition | PromotionAction) => { + return { + code: source.code, + description: source.description, + args: Object.entries(source.args).map(([name, type]) => ({ name, type })), + }; + }; return { - conditions: this.availableConditions.filter(o => o.type === type), - actions: this.availableActions.filter(o => o.type === type), + conditions: this.availableConditions.map(toAdjustmentOperation), + actions: this.availableActions.map(toAdjustmentOperation), }; } /** * Returns all active AdjustmentSources. */ - async getActiveAdjustmentSources(): Promise { - if (!this.activeSources.length) { - await this.updateActiveSources(); + async getActivePromotions(): Promise { + if (!this.activePromotions.length) { + await this.updatePromotions(); } - return this.activeSources; - } - - /** - * Returns the default tax category. - * TODO: currently just returns the first one. There should be a "default" flag. - */ - async getDefaultTaxCategory(): Promise { - const sources = await this.getActiveAdjustmentSources(); - const taxCategories = sources.filter(s => s.type === AdjustmentType.TAX); - return taxCategories[0]; + return this.activePromotions; } - async createAdjustmentSource( - ctx: RequestContext, - input: CreateAdjustmentSourceInput, - ): Promise { - const adjustmentSource = new AdjustmentSource({ + async createPromotion(ctx: RequestContext, input: CreatePromotionInput): Promise { + const adjustmentSource = new Promotion({ name: input.name, - type: input.type, enabled: input.enabled, 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); - await this.updateActiveSources(); + await this.updatePromotions(); return assertFound(this.findOne(newAdjustmentSource.id)); } - async updateAdjustmentSource( - ctx: RequestContext, - input: UpdateAdjustmentSourceInput, - ): Promise { - const adjustmentSource = await this.connection.getRepository(AdjustmentSource).findOne(input.id); + async updatePromotion(ctx: RequestContext, input: UpdatePromotionInput): Promise { + const adjustmentSource = await this.connection.getRepository(Promotion).findOne(input.id); if (!adjustmentSource) { throw new I18nError(`error.entity-with-id-not-found`, { entityName: 'AdjustmentSource', @@ -134,7 +117,7 @@ export class AdjustmentSourceService { updatedAdjustmentSource.actions = input.actions.map(a => this.parseOperationArgs('action', a)); } await this.connection.manager.save(updatedAdjustmentSource); - await this.updateActiveSources(); + await this.updatePromotions(); return assertFound(this.findOne(updatedAdjustmentSource.id)); } @@ -148,21 +131,23 @@ export class AdjustmentSourceService { const match = this.getAdjustmentOperationByCode(type, input.code); const output: AdjustmentOperation = { code: input.code, - type: match.type, description: match.description, args: input.arguments.map((inputArg, i) => { return { - name: match.args[i].name, - type: match.args[i].type, - value: inputArg, + name: inputArg.name, + type: match.args[inputArg.name], + value: inputArg.value, }; }), }; return output; } - private getAdjustmentOperationByCode(type: 'condition' | 'action', code: string): AdjustmentOperation { - const available: AdjustmentOperation[] = + private getAdjustmentOperationByCode( + type: 'condition' | 'action', + code: string, + ): PromotionCondition | PromotionAction { + const available: Array = type === 'condition' ? this.availableConditions : this.availableActions; const match = available.find(a => a.code === code); if (!match) { @@ -174,8 +159,8 @@ export class AdjustmentSourceService { /** * Update the activeSources cache. */ - private async updateActiveSources() { - this.activeSources = await this.connection.getRepository(AdjustmentSource).find({ + private async updatePromotions() { + this.activePromotions = await this.connection.getRepository(Promotion).find({ where: { enabled: true }, }); } diff --git a/server/src/service/providers/tax-category.service.ts b/server/src/service/providers/tax-category.service.ts new file mode 100644 index 0000000000..9e58e2d53d --- /dev/null +++ b/server/src/service/providers/tax-category.service.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@nestjs/common'; +import { InjectConnection } from '@nestjs/typeorm'; +import { CreateTaxCategoryInput, UpdateTaxCategoryInput } from 'shared/generated-types'; +import { ID } from 'shared/shared-types'; +import { Connection } from 'typeorm'; + +import { assertFound } from '../../common/utils'; +import { TaxCategory } from '../../entity/tax-category/tax-category.entity'; +import { I18nError } from '../../i18n/i18n-error'; +import { patchEntity } from '../helpers/patch-entity'; + +@Injectable() +export class TaxCategoryService { + constructor(@InjectConnection() private connection: Connection) {} + + findAll(): Promise { + return this.connection.getRepository(TaxCategory).find(); + } + + findOne(taxCategoryId: ID): Promise { + return this.connection.getRepository(TaxCategory).findOne(taxCategoryId); + } + + async create(input: CreateTaxCategoryInput): Promise { + const taxCategory = new TaxCategory(input); + const newTaxCategory = await this.connection.getRepository(TaxCategory).save(taxCategory); + return assertFound(this.findOne(newTaxCategory.id)); + } + + async update(input: UpdateTaxCategoryInput): Promise { + const taxCategory = await this.findOne(input.id); + if (!taxCategory) { + throw new I18nError(`error.entity-with-id-not-found`, { + entityName: 'TaxCategory', + id: input.id, + }); + } + const updatedTaxCategory = patchEntity(taxCategory, input); + await this.connection.getRepository(TaxCategory).save(updatedTaxCategory); + return assertFound(this.findOne(taxCategory.id)); + } +} diff --git a/server/src/service/providers/tax-rate.service.ts b/server/src/service/providers/tax-rate.service.ts new file mode 100644 index 0000000000..02b6b4bf14 --- /dev/null +++ b/server/src/service/providers/tax-rate.service.ts @@ -0,0 +1,100 @@ +import { InjectConnection } from '@nestjs/typeorm'; +import { CreateTaxRateInput, UpdateTaxRateInput } from 'shared/generated-types'; +import { ID, PaginatedList } from 'shared/shared-types'; +import { Connection } from 'typeorm'; + +import { ListQueryOptions } from '../../common/types/common-types'; +import { assertFound } from '../../common/utils'; +import { CustomerGroup } from '../../entity/customer-group/customer-group.entity'; +import { TaxCategory } from '../../entity/tax-category/tax-category.entity'; +import { TaxRate } from '../../entity/tax-rate/tax-rate.entity'; +import { Zone } from '../../entity/zone/zone.entity'; +import { I18nError } from '../../i18n/i18n-error'; +import { buildListQuery } from '../helpers/build-list-query'; +import { getEntityOrThrow } from '../helpers/get-entity-or-throw'; +import { patchEntity } from '../helpers/patch-entity'; + +export class TaxRateService { + /** + * We cache all active TaxRates to avoid hitting the DB many times + * per request. + */ + private activeTaxRates: TaxRate[] = []; + + constructor(@InjectConnection() private connection: Connection) {} + + async initTaxRates() { + return this.updateActiveTaxRates(); + } + + findAll(options?: ListQueryOptions): Promise> { + return buildListQuery(this.connection, TaxRate, options, ['category', 'zone', 'customerGroup']) + .getManyAndCount() + .then(([items, totalItems]) => ({ + items, + totalItems, + })); + } + + findOne(taxRateId: ID): Promise { + return this.connection.manager.findOne(TaxRate, taxRateId, { + relations: ['category', 'zone', 'customerGroup'], + }); + } + + async create(input: CreateTaxRateInput): Promise { + const taxRate = new TaxRate(input); + taxRate.category = await getEntityOrThrow(this.connection, TaxCategory, input.categoryId); + taxRate.zone = await getEntityOrThrow(this.connection, Zone, input.zoneId); + if (input.customerGroupId) { + taxRate.customerGroup = await getEntityOrThrow( + this.connection, + CustomerGroup, + input.customerGroupId, + ); + } + const newTaxRate = await this.connection.getRepository(TaxRate).save(taxRate); + await this.updateActiveTaxRates(); + return assertFound(this.findOne(newTaxRate.id)); + } + + async update(input: UpdateTaxRateInput): Promise { + const taxRate = await this.findOne(input.id); + if (!taxRate) { + throw new I18nError(`error.entity-with-id-not-found`, { + entityName: 'TaxRate', + id: input.id, + }); + } + const updatedTaxRate = patchEntity(taxRate, input); + if (input.categoryId) { + updatedTaxRate.category = await getEntityOrThrow(this.connection, TaxCategory, input.categoryId); + } + if (input.zoneId) { + updatedTaxRate.category = await getEntityOrThrow(this.connection, Zone, input.zoneId); + } + if (input.customerGroupId) { + updatedTaxRate.customerGroup = await getEntityOrThrow( + this.connection, + CustomerGroup, + input.customerGroupId, + ); + } + await this.connection.getRepository(TaxRate).save(updatedTaxRate); + await this.updateActiveTaxRates(); + return assertFound(this.findOne(taxRate.id)); + } + + getActiveTaxRates(): TaxRate[] { + return this.activeTaxRates; + } + + private async updateActiveTaxRates() { + this.activeTaxRates = await this.connection.getRepository(TaxRate).find({ + relations: ['category', 'zone', 'customerGroup'], + where: { + enabled: true, + }, + }); + } +} diff --git a/server/src/service/providers/zone.service.ts b/server/src/service/providers/zone.service.ts index 0887aa2397..a2124d10e8 100644 --- a/server/src/service/providers/zone.service.ts +++ b/server/src/service/providers/zone.service.ts @@ -13,7 +13,7 @@ import { Connection } from 'typeorm'; import { assertFound } from '../../common/utils'; import { Country } from '../../entity/country/country.entity'; import { Zone } from '../../entity/zone/zone.entity'; -import { I18nError } from '../../i18n/i18n-error'; +import { getEntityOrThrow } from '../helpers/get-entity-or-throw'; import { patchEntity } from '../helpers/patch-entity'; @Injectable() @@ -42,7 +42,7 @@ export class ZoneService { } async update(input: UpdateZoneInput): Promise { - const zone = await this.getZoneOrThrow(input.id); + const zone = await getEntityOrThrow(this.connection, Zone, input.id); const updatedZone = patchEntity(zone, input); await this.connection.getRepository(Zone).save(updatedZone); return assertFound(this.findOne(zone.id)); @@ -50,7 +50,7 @@ export class ZoneService { async addMembersToZone(input: AddMembersToZoneMutationArgs): Promise { const countries = await this.getCountriesFromIds(input.memberIds); - const zone = await this.getZoneOrThrow(input.zoneId); + const zone = await getEntityOrThrow(this.connection, Zone, input.zoneId); const members = unique(zone.members.concat(countries), 'id'); zone.members = members; await this.connection.getRepository(Zone).save(zone); @@ -58,20 +58,12 @@ export class ZoneService { } async removeMembersFromZone(input: RemoveMembersFromZoneMutationArgs): Promise { - const zone = await this.getZoneOrThrow(input.zoneId); + const zone = await getEntityOrThrow(this.connection, Zone, input.zoneId); zone.members = zone.members.filter(country => !input.memberIds.includes(country.id as string)); await this.connection.getRepository(Zone).save(zone); return zone; } - private async getZoneOrThrow(id: ID): Promise { - const zone = await this.findOne(id); - if (!zone) { - throw new I18nError(`error.entity-with-id-not-found`, { entityName: 'Zone', id }); - } - return zone; - } - private getCountriesFromIds(ids: ID[]): Promise { return this.connection.getRepository(Country).findByIds(ids); } diff --git a/server/src/service/service.module.ts b/server/src/service/service.module.ts index 389e5c8679..144ec060ce 100644 --- a/server/src/service/service.module.ts +++ b/server/src/service/service.module.ts @@ -5,8 +5,6 @@ import { ConfigModule } from '../config/config.module'; import { getConfig } from '../config/vendure-config'; import { TranslationUpdaterService } from './helpers/translation-updater.service'; -import { AdjustmentApplicatorService } from './providers/adjustment-applicator.service'; -import { AdjustmentSourceService } from './providers/adjustment-source.service'; import { AdministratorService } from './providers/administrator.service'; import { AssetService } from './providers/asset.service'; import { AuthService } from './providers/auth.service'; @@ -22,11 +20,14 @@ import { ProductOptionGroupService } from './providers/product-option-group.serv import { ProductOptionService } from './providers/product-option.service'; import { ProductVariantService } from './providers/product-variant.service'; import { ProductService } from './providers/product.service'; +import { PromotionService } from './providers/promotion.service'; import { RoleService } from './providers/role.service'; +import { TaxCategoryService } from './providers/tax-category.service'; +import { TaxRateService } from './providers/tax-rate.service'; import { ZoneService } from './providers/zone.service'; const exportedProviders = [ - AdjustmentSourceService, + PromotionService, AdministratorService, AssetService, AuthService, @@ -42,6 +43,8 @@ const exportedProviders = [ ProductService, ProductVariantService, RoleService, + TaxCategoryService, + TaxRateService, ZoneService, ]; @@ -54,12 +57,7 @@ const exportedProviders = [ */ @Module({ imports: [ConfigModule, TypeOrmModule.forRoot(getConfig().dbConnectionOptions)], - providers: [ - ...exportedProviders, - PasswordService, - TranslationUpdaterService, - AdjustmentApplicatorService, - ], + providers: [...exportedProviders, PasswordService, TranslationUpdaterService], exports: exportedProviders, }) export class ServiceModule implements OnModuleInit { @@ -67,11 +65,13 @@ export class ServiceModule implements OnModuleInit { private channelService: ChannelService, private roleService: RoleService, private administratorService: AdministratorService, + private taxRateService: TaxRateService, ) {} async onModuleInit() { await this.channelService.initChannels(); await this.roleService.initRoles(); await this.administratorService.initAdministrators(); + await this.taxRateService.initTaxRates(); } } diff --git a/shared/generated-types.ts b/shared/generated-types.ts index 93d079c790..9c3fe1af46 100644 --- a/shared/generated-types.ts +++ b/shared/generated-types.ts @@ -29,24 +29,23 @@ 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; asset?: Asset | null; me?: CurrentUser | null; + channels: Channel[]; + channel?: Channel | null; config: Config; countries: CountryList; country?: Country | null; @@ -63,8 +62,15 @@ export interface Query { productOptionGroup?: ProductOptionGroup | null; products: ProductList; product?: Product | null; + promotion?: Promotion | null; + promotions: PromotionList; + adjustmentOperations: AdjustmentOperations; roles: RoleList; role?: Role | null; + taxCategories: TaxCategory[]; + taxCategory?: TaxCategory | null; + taxRates: TaxRateList; + taxRate?: TaxRate | null; zones: Zone[]; zone?: Zone | null; networkStatus: NetworkStatus; @@ -72,40 +78,6 @@ export interface Query { uiState: UiState; } -export interface AdjustmentSource extends Node { - id: string; - createdAt: DateTime; - updatedAt: DateTime; - name: string; - type: AdjustmentType; - enabled: boolean; - 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; @@ -134,6 +106,8 @@ export interface User extends Node { export interface Role extends Node { id: string; + createdAt: DateTime; + updatedAt: DateTime; code: string; description: string; permissions: Permission[]; @@ -146,6 +120,24 @@ export interface Channel extends Node { updatedAt: DateTime; code: string; token: string; + defaultTaxZone?: Zone | null; + defaultShippingZone?: Zone | null; + defaultLanguageCode: LanguageCode; +} + +export interface Zone extends Node { + id: string; + createdAt: DateTime; + updatedAt: DateTime; + name: string; + members: Country[]; +} + +export interface Country extends Node { + id: string; + code: string; + name: string; + enabled: boolean; } export interface AssetList extends PaginatedList { @@ -178,13 +170,6 @@ export interface CountryList extends PaginatedList { totalItems: number; } -export interface Country extends Node { - id: string; - code: string; - name: string; - enabled: boolean; -} - export interface CustomerGroup extends Node { id: string; createdAt: DateTime; @@ -287,21 +272,23 @@ export interface Order extends Node { updatedAt: DateTime; code: string; customer?: Customer | null; - items: OrderItem[]; - adjustments: Adjustment[]; + lines: OrderLine[]; + totalPriceBeforeTax: number; totalPrice: number; } -export interface OrderItem extends Node { +export interface OrderLine extends Node { id: string; createdAt: DateTime; updatedAt: DateTime; productVariant: ProductVariant; - adjustments: Adjustment[]; featuredAsset?: Asset | null; unitPrice: number; + unitPriceWithTax: number; quantity: number; + items: OrderItem[]; totalPrice: number; + adjustments: Adjustment[]; order: Order; } @@ -312,19 +299,33 @@ export interface ProductVariant extends Node { languageCode: LanguageCode; sku: string; name: string; - priceBeforeTax: number; price: number; - taxCategory: ProductTaxCategory; + priceWithTax: number; + taxRateApplied?: TaxRate | null; + taxCategory: TaxCategory; options: ProductOption[]; facetValues: FacetValue[]; translations: ProductVariantTranslation[]; customFields?: Json | null; } -export interface ProductTaxCategory { +export interface TaxRate extends Node { + id: string; + createdAt: DateTime; + updatedAt: DateTime; + name: string; + enabled: boolean; + value: number; + category: TaxCategory; + zone: Zone; + customerGroup?: CustomerGroup | null; +} + +export interface TaxCategory extends Node { id: string; + createdAt: DateTime; + updatedAt: DateTime; name: string; - taxRate: number; } export interface ProductOption extends Node { @@ -354,8 +355,15 @@ export interface ProductVariantTranslation { name: string; } +export interface OrderItem extends Node { + id: string; + createdAt: DateTime; + updatedAt: DateTime; +} + export interface Adjustment { - adjustmentSourceId: string; + adjustmentSource: string; + type: AdjustmentType; description: string; amount: number; } @@ -427,17 +435,46 @@ export interface ProductCustomFields { nickname?: string | null; } +export interface Promotion extends Node { + id: string; + createdAt: DateTime; + updatedAt: DateTime; + name: string; + enabled: boolean; + conditions: AdjustmentOperation[]; + actions: AdjustmentOperation[]; +} + +export interface AdjustmentOperation { + code: string; + args: AdjustmentArg[]; + description: string; +} + +export interface AdjustmentArg { + name: string; + type: string; + value?: string | null; +} + +export interface PromotionList extends PaginatedList { + items: Promotion[]; + totalItems: number; +} + +export interface AdjustmentOperations { + conditions: AdjustmentOperation[]; + actions: AdjustmentOperation[]; +} + export interface RoleList extends PaginatedList { items: Role[]; totalItems: number; } -export interface Zone extends Node { - id: string; - createdAt: DateTime; - updatedAt: DateTime; - name: string; - members: Country[]; +export interface TaxRateList extends PaginatedList { + items: TaxRate[]; + totalItems: number; } export interface NetworkStatus { @@ -455,8 +492,6 @@ export interface UiState { } export interface Mutation { - createAdjustmentSource: AdjustmentSource; - updateAdjustmentSource: AdjustmentSource; createAdministrator: Administrator; updateAdministrator: Administrator; assignRoleToAdministrator: Administrator; @@ -464,6 +499,7 @@ export interface Mutation { login: LoginResult; logout: boolean; createChannel: Channel; + updateChannel: Channel; createCountry: Country; updateCountry: Country; createCustomerGroup: CustomerGroup; @@ -488,8 +524,14 @@ export interface Mutation { generateVariantsForProduct: Product; updateProductVariants: (ProductVariant | null)[]; applyFacetValuesToProductVariants: ProductVariant[]; + createPromotion: Promotion; + updatePromotion: Promotion; createRole: Role; updateRole: Role; + createTaxCategory: TaxCategory; + updateTaxCategory: TaxCategory; + createTaxRate: TaxRate; + updateTaxRate: TaxRate; createZone: Zone; updateZone: Zone; addMembersToZone: Zone; @@ -505,25 +547,28 @@ export interface LoginResult { user: CurrentUser; } -export interface AdjustmentSourceListOptions { +export interface AdministratorListOptions { take?: number | null; skip?: number | null; - sort?: AdjustmentSourceSortParameter | null; - filter?: AdjustmentSourceFilterParameter | null; + sort?: AdministratorSortParameter | null; + filter?: AdministratorFilterParameter | null; } -export interface AdjustmentSourceSortParameter { +export interface AdministratorSortParameter { id?: SortOrder | null; createdAt?: SortOrder | null; updatedAt?: SortOrder | null; - name?: SortOrder | null; + firstName?: SortOrder | null; + lastName?: SortOrder | null; + emailAddress?: SortOrder | null; } -export interface AdjustmentSourceFilterParameter { - name?: StringOperators | null; +export interface AdministratorFilterParameter { + firstName?: StringOperators | null; + lastName?: StringOperators | null; + emailAddress?: StringOperators | null; createdAt?: DateOperators | null; updatedAt?: DateOperators | null; - type?: StringOperators | null; } export interface StringOperators { @@ -543,30 +588,6 @@ 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; @@ -719,6 +740,27 @@ export interface ProductFilterParameter { nickname?: StringOperators | null; } +export interface PromotionListOptions { + take?: number | null; + skip?: number | null; + sort?: PromotionSortParameter | null; + filter?: PromotionFilterParameter | null; +} + +export interface PromotionSortParameter { + id?: SortOrder | null; + createdAt?: SortOrder | null; + updatedAt?: SortOrder | null; + name?: SortOrder | null; +} + +export interface PromotionFilterParameter { + name?: StringOperators | null; + createdAt?: DateOperators | null; + updatedAt?: DateOperators | null; + type?: StringOperators | null; +} + export interface RoleListOptions { take?: number | null; skip?: number | null; @@ -741,25 +783,27 @@ export interface RoleFilterParameter { updatedAt?: DateOperators | null; } -export interface CreateAdjustmentSourceInput { - name: string; - type: AdjustmentType; - enabled: boolean; - conditions: AdjustmentOperationInput[]; - actions: AdjustmentOperationInput[]; +export interface TaxRateListOptions { + take?: number | null; + skip?: number | null; + sort?: TaxRateSortParameter | null; + filter?: TaxRateFilterParameter | null; } -export interface AdjustmentOperationInput { - code: string; - arguments: string[]; +export interface TaxRateSortParameter { + id?: SortOrder | null; + createdAt?: SortOrder | null; + updatedAt?: SortOrder | null; + name?: SortOrder | null; + enabled?: SortOrder | null; } -export interface UpdateAdjustmentSourceInput { - id: string; - name?: string | null; - enabled?: boolean | null; - conditions?: AdjustmentOperationInput[] | null; - actions?: AdjustmentOperationInput[] | null; +export interface TaxRateFilterParameter { + code?: StringOperators | null; + name?: StringOperators | null; + enabled?: BooleanOperators | null; + createdAt?: DateOperators | null; + updatedAt?: DateOperators | null; } export interface CreateAdministratorInput { @@ -783,6 +827,23 @@ export interface CreateAssetInput { file: Upload; } +export interface CreateChannelInput { + code: string; + token: string; + defaultLanguageCode: LanguageCode; + defaultTaxZoneId?: string | null; + defaultShippingZoneId?: string | null; +} + +export interface UpdateChannelInput { + id: string; + code?: string | null; + token?: string | null; + defaultLanguageCode?: LanguageCode | null; + defaultTaxZoneId?: string | null; + defaultShippingZoneId?: string | null; +} + export interface CreateCountryInput { code: string; name: string; @@ -964,7 +1025,6 @@ export interface UpdateProductVariantInput { translations?: ProductVariantTranslationInput[] | null; sku?: string | null; taxCategoryId?: string | null; - priceBeforeTax?: number | null; price?: number | null; customFields?: Json | null; } @@ -976,6 +1036,31 @@ export interface ProductVariantTranslationInput { customFields?: Json | null; } +export interface CreatePromotionInput { + name: string; + enabled: boolean; + conditions: AdjustmentOperationInput[]; + actions: AdjustmentOperationInput[]; +} + +export interface AdjustmentOperationInput { + code: string; + arguments: AdjustmentOperationInputArg[]; +} + +export interface AdjustmentOperationInputArg { + name: string; + value: string; +} + +export interface UpdatePromotionInput { + id: string; + name?: string | null; + enabled?: boolean | null; + conditions?: AdjustmentOperationInput[] | null; + actions?: AdjustmentOperationInput[] | null; +} + export interface CreateRoleInput { code: string; description: string; @@ -989,6 +1074,34 @@ export interface UpdateRoleInput { permissions?: Permission[] | null; } +export interface CreateTaxCategoryInput { + name: string; +} + +export interface UpdateTaxCategoryInput { + id: string; + name?: string | null; +} + +export interface CreateTaxRateInput { + name: string; + enabled: boolean; + value: number; + categoryId: string; + zoneId: string; + customerGroupId?: string | null; +} + +export interface UpdateTaxRateInput { + id: string; + name?: string | null; + value?: number | null; + enabled?: boolean | null; + categoryId?: string | null; + zoneId?: string | null; + customerGroupId?: string | null; +} + export interface CreateZoneInput { name: string; memberIds?: string[] | null; @@ -1002,7 +1115,6 @@ export interface UpdateZoneInput { export interface CreateProductVariantInput { translations: ProductVariantTranslationInput[]; sku: string; - priceBeforeTax?: number | null; price?: number | null; taxCategoryId: string; optionCodes?: string[] | null; @@ -1029,16 +1141,6 @@ 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; } @@ -1051,6 +1153,9 @@ export interface AssetsQueryArgs { export interface AssetQueryArgs { id: string; } +export interface ChannelQueryArgs { + id: string; +} export interface CountriesQueryArgs { options?: CountryListOptions | null; } @@ -1096,20 +1201,29 @@ export interface ProductQueryArgs { id: string; languageCode?: LanguageCode | null; } +export interface PromotionQueryArgs { + id: string; +} +export interface PromotionsQueryArgs { + options?: PromotionListOptions | null; +} export interface RolesQueryArgs { options?: RoleListOptions | null; } export interface RoleQueryArgs { id: string; } -export interface ZoneQueryArgs { +export interface TaxCategoryQueryArgs { id: string; } -export interface CreateAdjustmentSourceMutationArgs { - input: CreateAdjustmentSourceInput; +export interface TaxRatesQueryArgs { + options?: TaxRateListOptions | null; } -export interface UpdateAdjustmentSourceMutationArgs { - input: UpdateAdjustmentSourceInput; +export interface TaxRateQueryArgs { + id: string; +} +export interface ZoneQueryArgs { + id: string; } export interface CreateAdministratorMutationArgs { input: CreateAdministratorInput; @@ -1130,7 +1244,10 @@ export interface LoginMutationArgs { rememberMe?: boolean | null; } export interface CreateChannelMutationArgs { - code: string; + input: CreateChannelInput; +} +export interface UpdateChannelMutationArgs { + input: UpdateChannelInput; } export interface CreateCountryMutationArgs { input: CreateCountryInput; @@ -1216,12 +1333,30 @@ export interface ApplyFacetValuesToProductVariantsMutationArgs { facetValueIds: string[]; productVariantIds: string[]; } +export interface CreatePromotionMutationArgs { + input: CreatePromotionInput; +} +export interface UpdatePromotionMutationArgs { + input: UpdatePromotionInput; +} export interface CreateRoleMutationArgs { input: CreateRoleInput; } export interface UpdateRoleMutationArgs { input: UpdateRoleInput; } +export interface CreateTaxCategoryMutationArgs { + input: CreateTaxCategoryInput; +} +export interface UpdateTaxCategoryMutationArgs { + input: UpdateTaxCategoryInput; +} +export interface CreateTaxRateMutationArgs { + input: CreateTaxRateInput; +} +export interface UpdateTaxRateMutationArgs { + input: UpdateTaxRateInput; +} export interface CreateZoneMutationArgs { input: CreateZoneInput; } @@ -1244,12 +1379,6 @@ export interface SetUiLanguageMutationArgs { languageCode?: LanguageCode | null; } -export enum AdjustmentType { - TAX = 'TAX', - PROMOTION = 'PROMOTION', - SHIPPING = 'SHIPPING', -} - export enum SortOrder { ASC = 'ASC', DESC = 'DESC', @@ -1286,12 +1415,6 @@ export enum Permission { DeleteSettings = 'DeleteSettings', } -export enum AssetType { - IMAGE = 'IMAGE', - VIDEO = 'VIDEO', - BINARY = 'BINARY', -} - export enum LanguageCode { aa = 'aa', ab = 'ab', @@ -1479,16 +1602,29 @@ export enum LanguageCode { zu = 'zu', } +export enum AssetType { + IMAGE = 'IMAGE', + VIDEO = 'VIDEO', + BINARY = 'BINARY', +} + +export enum AdjustmentType { + TAX = 'TAX', + PROMOTION = 'PROMOTION', + REFUND = 'REFUND', + TAX_REFUND = 'TAX_REFUND', + PROMOTION_REFUND = 'PROMOTION_REFUND', +} + export namespace QueryResolvers { export interface Resolvers { - adjustmentSource?: AdjustmentSourceResolver; - adjustmentSources?: AdjustmentSourcesResolver; - adjustmentOperations?: AdjustmentOperationsResolver; administrators?: AdministratorsResolver; administrator?: AdministratorResolver; assets?: AssetsResolver; asset?: AssetResolver; me?: MeResolver; + channels?: ChannelsResolver; + channel?: ChannelResolver; config?: ConfigResolver; countries?: CountriesResolver; country?: CountryResolver; @@ -1505,8 +1641,15 @@ export namespace QueryResolvers { productOptionGroup?: ProductOptionGroupResolver; products?: ProductsResolver; product?: ProductResolver; + promotion?: PromotionResolver; + promotions?: PromotionsResolver; + adjustmentOperations?: AdjustmentOperationsResolver; roles?: RolesResolver; role?: RoleResolver; + taxCategories?: TaxCategoriesResolver; + taxCategory?: TaxCategoryResolver; + taxRates?: TaxRatesResolver; + taxRate?: TaxRateResolver; zones?: ZonesResolver; zone?: ZoneResolver; networkStatus?: NetworkStatusResolver; @@ -1514,36 +1657,6 @@ export namespace QueryResolvers { uiState?: UiStateResolver; } - export type AdjustmentSourceResolver = Resolver< - R, - Parent, - Context, - AdjustmentSourceArgs - >; - export interface AdjustmentSourceArgs { - id: string; - } - - export type AdjustmentSourcesResolver = Resolver< - R, - Parent, - Context, - AdjustmentSourcesArgs - >; - export interface AdjustmentSourcesArgs { - type: AdjustmentType; - options?: AdjustmentSourceListOptions | null; - } - - export type AdjustmentOperationsResolver< - R = AdjustmentOperations, - Parent = any, - Context = any - > = Resolver; - export interface AdjustmentOperationsArgs { - type: AdjustmentType; - } - export type AdministratorsResolver = Resolver< R, Parent, @@ -1589,6 +1702,17 @@ export namespace QueryResolvers { Parent, Context >; + export type ChannelsResolver = Resolver; + export type ChannelResolver = Resolver< + R, + Parent, + Context, + ChannelArgs + >; + export interface ChannelArgs { + id: string; + } + export type ConfigResolver = Resolver; export type CountriesResolver = Resolver< R, @@ -1735,6 +1859,31 @@ export namespace QueryResolvers { languageCode?: LanguageCode | null; } + export type PromotionResolver = Resolver< + R, + Parent, + Context, + PromotionArgs + >; + export interface PromotionArgs { + id: string; + } + + export type PromotionsResolver = Resolver< + R, + Parent, + Context, + PromotionsArgs + >; + export interface PromotionsArgs { + options?: PromotionListOptions | null; + } + + export type AdjustmentOperationsResolver< + R = AdjustmentOperations, + Parent = any, + Context = any + > = Resolver; export type RolesResolver = Resolver< R, Parent, @@ -1755,116 +1904,63 @@ export namespace QueryResolvers { id: string; } - export type ZonesResolver = Resolver; - export type ZoneResolver = Resolver< - R, - Parent, - Context, - ZoneArgs - >; - export interface ZoneArgs { - id: string; - } - - export type NetworkStatusResolver = Resolver< + export type TaxCategoriesResolver = Resolver< R, Parent, Context >; - export type UserStatusResolver = Resolver< + export type TaxCategoryResolver = Resolver< R, Parent, - Context + Context, + TaxCategoryArgs >; - export type UiStateResolver = Resolver; -} - -export namespace AdjustmentSourceResolvers { - export interface Resolvers { - id?: IdResolver; - createdAt?: CreatedAtResolver; - updatedAt?: UpdatedAtResolver; - name?: NameResolver; - type?: TypeResolver; - enabled?: EnabledResolver; - conditions?: ConditionsResolver; - actions?: ActionsResolver; + export interface TaxCategoryArgs { + id: string; } - export type IdResolver = Resolver; - export type CreatedAtResolver = Resolver; - export type UpdatedAtResolver = Resolver; - export type NameResolver = Resolver; - export type TypeResolver = Resolver; - export type EnabledResolver = Resolver; - export type ConditionsResolver = Resolver< + export type TaxRatesResolver = Resolver< R, Parent, - Context + Context, + TaxRatesArgs >; - export type ActionsResolver = Resolver< + export interface TaxRatesArgs { + options?: TaxRateListOptions | null; + } + + export type TaxRateResolver = Resolver< R, Parent, - Context + Context, + TaxRateArgs >; -} - -export namespace AdjustmentOperationResolvers { - export interface Resolvers { - type?: TypeResolver; - code?: CodeResolver; - args?: ArgsResolver; - description?: DescriptionResolver; - } - - export type TypeResolver = Resolver; - export type CodeResolver = Resolver; - export type ArgsResolver = Resolver; - export type DescriptionResolver = Resolver; -} - -export namespace AdjustmentArgResolvers { - export interface Resolvers { - name?: NameResolver; - type?: TypeResolver; - value?: ValueResolver; - } - - export type NameResolver = Resolver; - export type TypeResolver = Resolver; - export type ValueResolver = Resolver; -} - -export namespace AdjustmentSourceListResolvers { - export interface Resolvers { - items?: ItemsResolver; - totalItems?: TotalItemsResolver; + export interface TaxRateArgs { + id: string; } - export type ItemsResolver = Resolver< + export type ZonesResolver = Resolver; + export type ZoneResolver = Resolver< R, Parent, - Context + Context, + ZoneArgs >; - export type TotalItemsResolver = Resolver; -} - -export namespace AdjustmentOperationsResolvers { - export interface Resolvers { - conditions?: ConditionsResolver; - actions?: ActionsResolver; + export interface ZoneArgs { + id: string; } - export type ConditionsResolver = Resolver< + export type NetworkStatusResolver = Resolver< R, Parent, Context >; - export type ActionsResolver = Resolver< + export type UserStatusResolver = Resolver< R, Parent, Context >; + export type UiStateResolver = Resolver; } export namespace AdministratorListResolvers { @@ -1934,6 +2030,8 @@ export namespace UserResolvers { export namespace RoleResolvers { export interface Resolvers { id?: IdResolver; + createdAt?: CreatedAtResolver; + updatedAt?: UpdatedAtResolver; code?: CodeResolver; description?: DescriptionResolver; permissions?: PermissionsResolver; @@ -1941,6 +2039,8 @@ export namespace RoleResolvers { } export type IdResolver = Resolver; + export type CreatedAtResolver = Resolver; + export type UpdatedAtResolver = Resolver; export type CodeResolver = Resolver; export type DescriptionResolver = Resolver; export type PermissionsResolver = Resolver< @@ -1958,6 +2058,9 @@ export namespace ChannelResolvers { updatedAt?: UpdatedAtResolver; code?: CodeResolver; token?: TokenResolver; + defaultTaxZone?: DefaultTaxZoneResolver; + defaultShippingZone?: DefaultShippingZoneResolver; + defaultLanguageCode?: DefaultLanguageCodeResolver; } export type IdResolver = Resolver; @@ -1965,6 +2068,51 @@ export namespace ChannelResolvers { export type UpdatedAtResolver = Resolver; export type CodeResolver = Resolver; export type TokenResolver = Resolver; + export type DefaultTaxZoneResolver = Resolver< + R, + Parent, + Context + >; + export type DefaultShippingZoneResolver = Resolver< + R, + Parent, + Context + >; + export type DefaultLanguageCodeResolver = Resolver< + R, + Parent, + Context + >; +} + +export namespace ZoneResolvers { + export interface Resolvers { + id?: IdResolver; + createdAt?: CreatedAtResolver; + updatedAt?: UpdatedAtResolver; + name?: NameResolver; + members?: MembersResolver; + } + + export type IdResolver = Resolver; + export type CreatedAtResolver = Resolver; + export type UpdatedAtResolver = Resolver; + export type NameResolver = Resolver; + export type MembersResolver = Resolver; +} + +export namespace CountryResolvers { + export interface Resolvers { + id?: IdResolver; + code?: CodeResolver; + name?: NameResolver; + enabled?: EnabledResolver; + } + + export type IdResolver = Resolver; + export type CodeResolver = Resolver; + export type NameResolver = Resolver; + export type EnabledResolver = Resolver; } export namespace AssetListResolvers { @@ -2035,20 +2183,6 @@ export namespace CountryListResolvers { export type TotalItemsResolver = Resolver; } -export namespace CountryResolvers { - export interface Resolvers { - id?: IdResolver; - code?: CodeResolver; - name?: NameResolver; - enabled?: EnabledResolver; - } - - export type IdResolver = Resolver; - export type CodeResolver = Resolver; - export type NameResolver = Resolver; - export type EnabledResolver = Resolver; -} - export namespace CustomerGroupResolvers { export interface Resolvers { id?: IdResolver; @@ -2344,8 +2478,8 @@ export namespace OrderResolvers { updatedAt?: UpdatedAtResolver; code?: CodeResolver; customer?: CustomerResolver; - items?: ItemsResolver; - adjustments?: AdjustmentsResolver; + lines?: LinesResolver; + totalPriceBeforeTax?: TotalPriceBeforeTaxResolver; totalPrice?: TotalPriceResolver; } @@ -2358,8 +2492,8 @@ export namespace OrderResolvers { Parent, Context >; - export type ItemsResolver = Resolver; - export type AdjustmentsResolver = Resolver< + export type LinesResolver = Resolver; + export type TotalPriceBeforeTaxResolver = Resolver< R, Parent, Context @@ -2367,17 +2501,19 @@ export namespace OrderResolvers { export type TotalPriceResolver = Resolver; } -export namespace OrderItemResolvers { +export namespace OrderLineResolvers { export interface Resolvers { id?: IdResolver; createdAt?: CreatedAtResolver; updatedAt?: UpdatedAtResolver; productVariant?: ProductVariantResolver; - adjustments?: AdjustmentsResolver; featuredAsset?: FeaturedAssetResolver; unitPrice?: UnitPriceResolver; + unitPriceWithTax?: UnitPriceWithTaxResolver; quantity?: QuantityResolver; + items?: ItemsResolver; totalPrice?: TotalPriceResolver; + adjustments?: AdjustmentsResolver; order?: OrderResolver; } @@ -2389,23 +2525,29 @@ export namespace OrderItemResolvers { Parent, Context >; - export type AdjustmentsResolver = Resolver< + export type FeaturedAssetResolver = Resolver< R, Parent, Context >; - export type FeaturedAssetResolver = Resolver< + export type UnitPriceResolver = Resolver; + export type UnitPriceWithTaxResolver = Resolver< R, Parent, Context >; - export type UnitPriceResolver = Resolver; export type QuantityResolver = Resolver; + export type ItemsResolver = Resolver; export type TotalPriceResolver = Resolver; - export type OrderResolver = Resolver; -} - -export namespace ProductVariantResolvers { + export type AdjustmentsResolver = Resolver< + R, + Parent, + Context + >; + export type OrderResolver = Resolver; +} + +export namespace ProductVariantResolvers { export interface Resolvers { id?: IdResolver; createdAt?: CreatedAtResolver; @@ -2413,9 +2555,10 @@ export namespace ProductVariantResolvers { languageCode?: LanguageCodeResolver; sku?: SkuResolver; name?: NameResolver; - priceBeforeTax?: PriceBeforeTaxResolver; price?: PriceResolver; - taxCategory?: TaxCategoryResolver; + priceWithTax?: PriceWithTaxResolver; + taxRateApplied?: TaxRateAppliedResolver; + taxCategory?: TaxCategoryResolver; options?: OptionsResolver; facetValues?: FacetValuesResolver; translations?: TranslationsResolver; @@ -2432,13 +2575,14 @@ export namespace ProductVariantResolvers { >; export type SkuResolver = Resolver; export type NameResolver = Resolver; - export type PriceBeforeTaxResolver = Resolver< + export type PriceResolver = Resolver; + export type PriceWithTaxResolver = Resolver; + export type TaxRateAppliedResolver = Resolver< R, Parent, Context >; - export type PriceResolver = Resolver; - export type TaxCategoryResolver = Resolver< + export type TaxCategoryResolver = Resolver< R, Parent, Context @@ -2465,16 +2609,46 @@ export namespace ProductVariantResolvers { >; } -export namespace ProductTaxCategoryResolvers { +export namespace TaxRateResolvers { + export interface Resolvers { + id?: IdResolver; + createdAt?: CreatedAtResolver; + updatedAt?: UpdatedAtResolver; + name?: NameResolver; + enabled?: EnabledResolver; + value?: ValueResolver; + category?: CategoryResolver; + zone?: ZoneResolver; + customerGroup?: CustomerGroupResolver; + } + + export type IdResolver = Resolver; + export type CreatedAtResolver = Resolver; + export type UpdatedAtResolver = Resolver; + export type NameResolver = Resolver; + export type EnabledResolver = Resolver; + export type ValueResolver = Resolver; + export type CategoryResolver = Resolver; + export type ZoneResolver = Resolver; + export type CustomerGroupResolver = Resolver< + R, + Parent, + Context + >; +} + +export namespace TaxCategoryResolvers { export interface Resolvers { id?: IdResolver; + createdAt?: CreatedAtResolver; + updatedAt?: UpdatedAtResolver; name?: NameResolver; - taxRate?: TaxRateResolver; } export type IdResolver = Resolver; + export type CreatedAtResolver = Resolver; + export type UpdatedAtResolver = Resolver; export type NameResolver = Resolver; - export type TaxRateResolver = Resolver; } export namespace ProductOptionResolvers { @@ -2551,18 +2725,32 @@ export namespace ProductVariantTranslationResolvers { export type NameResolver = Resolver; } +export namespace OrderItemResolvers { + export interface Resolvers { + id?: IdResolver; + createdAt?: CreatedAtResolver; + updatedAt?: UpdatedAtResolver; + } + + export type IdResolver = Resolver; + export type CreatedAtResolver = Resolver; + export type UpdatedAtResolver = Resolver; +} + export namespace AdjustmentResolvers { export interface Resolvers { - adjustmentSourceId?: AdjustmentSourceIdResolver; + adjustmentSource?: AdjustmentSourceResolver; + type?: TypeResolver; description?: DescriptionResolver; amount?: AmountResolver; } - export type AdjustmentSourceIdResolver = Resolver< + export type AdjustmentSourceResolver = Resolver< R, Parent, Context >; + export type TypeResolver = Resolver; export type DescriptionResolver = Resolver; export type AmountResolver = Resolver; } @@ -2769,6 +2957,86 @@ export namespace ProductCustomFieldsResolvers { >; } +export namespace PromotionResolvers { + export interface Resolvers { + id?: IdResolver; + createdAt?: CreatedAtResolver; + updatedAt?: UpdatedAtResolver; + name?: NameResolver; + enabled?: EnabledResolver; + conditions?: ConditionsResolver; + actions?: ActionsResolver; + } + + export type IdResolver = Resolver; + export type CreatedAtResolver = Resolver; + export type UpdatedAtResolver = Resolver; + export type NameResolver = Resolver; + export type EnabledResolver = Resolver; + export type ConditionsResolver = Resolver< + R, + Parent, + Context + >; + export type ActionsResolver = Resolver< + R, + Parent, + Context + >; +} + +export namespace AdjustmentOperationResolvers { + export interface Resolvers { + code?: CodeResolver; + args?: ArgsResolver; + description?: DescriptionResolver; + } + + export type CodeResolver = Resolver; + export type ArgsResolver = Resolver; + export type DescriptionResolver = Resolver; +} + +export namespace AdjustmentArgResolvers { + export interface Resolvers { + name?: NameResolver; + type?: TypeResolver; + value?: ValueResolver; + } + + export type NameResolver = Resolver; + export type TypeResolver = Resolver; + export type ValueResolver = Resolver; +} + +export namespace PromotionListResolvers { + export interface Resolvers { + items?: ItemsResolver; + totalItems?: TotalItemsResolver; + } + + export type ItemsResolver = Resolver; + export type TotalItemsResolver = Resolver; +} + +export namespace AdjustmentOperationsResolvers { + export interface Resolvers { + conditions?: ConditionsResolver; + actions?: ActionsResolver; + } + + export type ConditionsResolver = Resolver< + R, + Parent, + Context + >; + export type ActionsResolver = Resolver< + R, + Parent, + Context + >; +} + export namespace RoleListResolvers { export interface Resolvers { items?: ItemsResolver; @@ -2779,20 +3047,14 @@ export namespace RoleListResolvers { export type TotalItemsResolver = Resolver; } -export namespace ZoneResolvers { +export namespace TaxRateListResolvers { export interface Resolvers { - id?: IdResolver; - createdAt?: CreatedAtResolver; - updatedAt?: UpdatedAtResolver; - name?: NameResolver; - members?: MembersResolver; + items?: ItemsResolver; + totalItems?: TotalItemsResolver; } - export type IdResolver = Resolver; - export type CreatedAtResolver = Resolver; - export type UpdatedAtResolver = Resolver; - export type NameResolver = Resolver; - export type MembersResolver = Resolver; + export type ItemsResolver = Resolver; + export type TotalItemsResolver = Resolver; } export namespace NetworkStatusResolvers { @@ -2833,8 +3095,6 @@ export namespace UiStateResolvers { export namespace MutationResolvers { export interface Resolvers { - createAdjustmentSource?: CreateAdjustmentSourceResolver; - updateAdjustmentSource?: UpdateAdjustmentSourceResolver; createAdministrator?: CreateAdministratorResolver; updateAdministrator?: UpdateAdministratorResolver; assignRoleToAdministrator?: AssignRoleToAdministratorResolver; @@ -2842,6 +3102,7 @@ export namespace MutationResolvers { login?: LoginResolver; logout?: LogoutResolver; createChannel?: CreateChannelResolver; + updateChannel?: UpdateChannelResolver; createCountry?: CreateCountryResolver; updateCountry?: UpdateCountryResolver; createCustomerGroup?: CreateCustomerGroupResolver; @@ -2870,8 +3131,14 @@ export namespace MutationResolvers { any, Context >; + createPromotion?: CreatePromotionResolver; + updatePromotion?: UpdatePromotionResolver; createRole?: CreateRoleResolver; updateRole?: UpdateRoleResolver; + createTaxCategory?: CreateTaxCategoryResolver; + updateTaxCategory?: UpdateTaxCategoryResolver; + createTaxRate?: CreateTaxRateResolver; + updateTaxRate?: UpdateTaxRateResolver; createZone?: CreateZoneResolver; updateZone?: UpdateZoneResolver; addMembersToZone?: AddMembersToZoneResolver; @@ -2883,26 +3150,6 @@ export namespace MutationResolvers { setUiLanguage?: SetUiLanguageResolver; } - export type CreateAdjustmentSourceResolver = Resolver< - R, - Parent, - Context, - CreateAdjustmentSourceArgs - >; - export interface CreateAdjustmentSourceArgs { - input: CreateAdjustmentSourceInput; - } - - export type UpdateAdjustmentSourceResolver = Resolver< - R, - Parent, - Context, - UpdateAdjustmentSourceArgs - >; - export interface UpdateAdjustmentSourceArgs { - input: UpdateAdjustmentSourceInput; - } - export type CreateAdministratorResolver = Resolver< R, Parent, @@ -2964,7 +3211,17 @@ export namespace MutationResolvers { CreateChannelArgs >; export interface CreateChannelArgs { - code: string; + input: CreateChannelInput; + } + + export type UpdateChannelResolver = Resolver< + R, + Parent, + Context, + UpdateChannelArgs + >; + export interface UpdateChannelArgs { + input: UpdateChannelInput; } export type CreateCountryResolver = Resolver< @@ -3215,6 +3472,26 @@ export namespace MutationResolvers { productVariantIds: string[]; } + export type CreatePromotionResolver = Resolver< + R, + Parent, + Context, + CreatePromotionArgs + >; + export interface CreatePromotionArgs { + input: CreatePromotionInput; + } + + export type UpdatePromotionResolver = Resolver< + R, + Parent, + Context, + UpdatePromotionArgs + >; + export interface UpdatePromotionArgs { + input: UpdatePromotionInput; + } + export type CreateRoleResolver = Resolver< R, Parent, @@ -3235,6 +3512,46 @@ export namespace MutationResolvers { input: UpdateRoleInput; } + export type CreateTaxCategoryResolver = Resolver< + R, + Parent, + Context, + CreateTaxCategoryArgs + >; + export interface CreateTaxCategoryArgs { + input: CreateTaxCategoryInput; + } + + export type UpdateTaxCategoryResolver = Resolver< + R, + Parent, + Context, + UpdateTaxCategoryArgs + >; + export interface UpdateTaxCategoryArgs { + input: UpdateTaxCategoryInput; + } + + export type CreateTaxRateResolver = Resolver< + R, + Parent, + Context, + CreateTaxRateArgs + >; + export interface CreateTaxRateArgs { + input: CreateTaxRateInput; + } + + export type UpdateTaxRateResolver = Resolver< + R, + Parent, + Context, + UpdateTaxRateArgs + >; + export interface UpdateTaxRateArgs { + input: UpdateTaxRateInput; + } + export type CreateZoneResolver = Resolver< R, Parent, @@ -3322,86 +3639,6 @@ export namespace LoginResultResolvers { export type UserResolver = Resolver; } -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; @@ -4045,7 +4282,84 @@ export namespace CreateAssets { export type CreateAssets = Asset.Fragment; } -export namespace GetCountryList { +export namespace GetPromotionList { + export type Variables = { + options?: PromotionListOptions | null; + }; + + export type Query = { + __typename?: 'Query'; + promotions: Promotions; + }; + + export type Promotions = { + __typename?: 'PromotionList'; + items: Items[]; + totalItems: number; + }; + + export type Items = Promotion.Fragment; +} + +export namespace GetPromotion { + export type Variables = { + id: string; + }; + + export type Query = { + __typename?: 'Query'; + promotion?: Promotion | null; + }; + + export type Promotion = Promotion.Fragment; +} + +export namespace GetAdjustmentOperations { + export type Variables = {}; + + 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 CreatePromotion { + export type Variables = { + input: CreatePromotionInput; + }; + + export type Mutation = { + __typename?: 'Mutation'; + createPromotion: CreatePromotion; + }; + + export type CreatePromotion = Promotion.Fragment; +} + +export namespace UpdatePromotion { + export type Variables = { + input: UpdatePromotionInput; + }; + + export type Mutation = { + __typename?: 'Mutation'; + updatePromotion: UpdatePromotion; + }; + + export type UpdatePromotion = Promotion.Fragment; +} + +export namespace GetCountryList { export type Variables = { options?: CountryListOptions | null; }; @@ -4181,39 +4495,162 @@ export namespace RemoveMembersFromZone { export type RemoveMembersFromZone = Zone.Fragment; } -export namespace AdjustmentOperation { - export type Fragment = { - __typename?: 'AdjustmentOperation'; - args: Args[]; - code: string; - description: string; - type: AdjustmentType; +export namespace GetTaxCategories { + export type Variables = {}; + + export type Query = { + __typename?: 'Query'; + taxCategories: TaxCategories[]; }; - export type Args = { - __typename?: 'AdjustmentArg'; - name: string; - type: string; - value?: string | null; + export type TaxCategories = TaxCategory.Fragment; +} + +export namespace GetTaxCategory { + export type Variables = { + id: string; }; + + export type Query = { + __typename?: 'Query'; + taxCategory?: TaxCategory | null; + }; + + export type TaxCategory = TaxCategory.Fragment; } -export namespace AdjustmentSource { - export type Fragment = { - __typename?: 'AdjustmentSource'; +export namespace CreateTaxCategory { + export type Variables = { + input: CreateTaxCategoryInput; + }; + + export type Mutation = { + __typename?: 'Mutation'; + createTaxCategory: CreateTaxCategory; + }; + + export type CreateTaxCategory = TaxCategory.Fragment; +} + +export namespace UpdateTaxCategory { + export type Variables = { + input: UpdateTaxCategoryInput; + }; + + export type Mutation = { + __typename?: 'Mutation'; + updateTaxCategory: UpdateTaxCategory; + }; + + export type UpdateTaxCategory = TaxCategory.Fragment; +} + +export namespace GetTaxRateList { + export type Variables = { + options?: TaxRateListOptions | null; + }; + + export type Query = { + __typename?: 'Query'; + taxRates: TaxRates; + }; + + export type TaxRates = { + __typename?: 'TaxRateList'; + items: Items[]; + totalItems: number; + }; + + export type Items = TaxRate.Fragment; +} + +export namespace GetTaxRate { + export type Variables = { id: string; - createdAt: DateTime; - updatedAt: DateTime; - name: string; - type: AdjustmentType; - enabled: boolean; - conditions: Conditions[]; - actions: Actions[]; }; - export type Conditions = AdjustmentOperation.Fragment; + export type Query = { + __typename?: 'Query'; + taxRate?: TaxRate | null; + }; - export type Actions = AdjustmentOperation.Fragment; + export type TaxRate = TaxRate.Fragment; +} + +export namespace CreateTaxRate { + export type Variables = { + input: CreateTaxRateInput; + }; + + export type Mutation = { + __typename?: 'Mutation'; + createTaxRate: CreateTaxRate; + }; + + export type CreateTaxRate = TaxRate.Fragment; +} + +export namespace UpdateTaxRate { + export type Variables = { + input: UpdateTaxRateInput; + }; + + export type Mutation = { + __typename?: 'Mutation'; + updateTaxRate: UpdateTaxRate; + }; + + export type UpdateTaxRate = TaxRate.Fragment; +} + +export namespace GetChannels { + export type Variables = {}; + + export type Query = { + __typename?: 'Query'; + channels: Channels[]; + }; + + export type Channels = Channel.Fragment; +} + +export namespace GetChannel { + export type Variables = { + id: string; + }; + + export type Query = { + __typename?: 'Query'; + channel?: Channel | null; + }; + + export type Channel = Channel.Fragment; +} + +export namespace CreateChannel { + export type Variables = { + input: CreateChannelInput; + }; + + export type Mutation = { + __typename?: 'Mutation'; + createChannel: CreateChannel; + }; + + export type CreateChannel = Channel.Fragment; +} + +export namespace UpdateChannel { + export type Variables = { + input: UpdateChannelInput; + }; + + export type Mutation = { + __typename?: 'Mutation'; + updateChannel: UpdateChannel; + }; + + export type UpdateChannel = Channel.Fragment; } export namespace Administrator { @@ -4346,7 +4783,8 @@ export namespace ProductVariant { languageCode: LanguageCode; name: string; price: number; - priceBeforeTax: number; + priceWithTax: number; + taxRateApplied?: TaxRateApplied | null; taxCategory: TaxCategory; sku: string; options: Options[]; @@ -4354,11 +4792,17 @@ export namespace ProductVariant { translations: Translations[]; }; + export type TaxRateApplied = { + __typename?: 'TaxRate'; + id: string; + name: string; + value: number; + }; + export type TaxCategory = { - __typename?: 'ProductTaxCategory'; + __typename?: 'TaxCategory'; id: string; name: string; - taxRate: number; }; export type Options = { @@ -4453,6 +4897,39 @@ export namespace ProductOptionGroup { }; } +export namespace AdjustmentOperation { + export type Fragment = { + __typename?: 'AdjustmentOperation'; + args: Args[]; + code: string; + description: string; + }; + + export type Args = { + __typename?: 'AdjustmentArg'; + name: string; + type: string; + value?: string | null; + }; +} + +export namespace Promotion { + export type Fragment = { + __typename?: 'Promotion'; + id: string; + createdAt: DateTime; + updatedAt: DateTime; + name: string; + enabled: boolean; + conditions: Conditions[]; + actions: Actions[]; + }; + + export type Conditions = AdjustmentOperation.Fragment; + + export type Actions = AdjustmentOperation.Fragment; +} + export namespace Country { export type Fragment = { __typename?: 'Country'; @@ -4473,3 +4950,66 @@ export namespace Zone { export type Members = Country.Fragment; } + +export namespace TaxCategory { + export type Fragment = { + __typename?: 'TaxCategory'; + id: string; + name: string; + }; +} + +export namespace TaxRate { + export type Fragment = { + __typename?: 'TaxRate'; + id: string; + name: string; + enabled: boolean; + value: number; + category: Category; + zone: Zone; + customerGroup?: CustomerGroup | null; + }; + + export type Category = { + __typename?: 'TaxCategory'; + id: string; + name: string; + }; + + export type Zone = { + __typename?: 'Zone'; + id: string; + name: string; + }; + + export type CustomerGroup = { + __typename?: 'CustomerGroup'; + id: string; + name: string; + }; +} + +export namespace Channel { + export type Fragment = { + __typename?: 'Channel'; + id: string; + code: string; + token: string; + defaultLanguageCode: LanguageCode; + defaultShippingZone?: DefaultShippingZone | null; + defaultTaxZone?: DefaultTaxZone | null; + }; + + export type DefaultShippingZone = { + __typename?: 'Zone'; + id: string; + name: string; + }; + + export type DefaultTaxZone = { + __typename?: 'Zone'; + id: string; + name: string; + }; +}