Browse Source

feat(server): Add tax data to ProductVariant results

Michael Bromley 7 years ago
parent
commit
b0d4b6c261

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

@@ -245,6 +245,7 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
                     sku: variant.sku,
                     name: variantTranslation ? variantTranslation.name : '',
                     price: variant.price,
+                    priceWithTax: variant.priceWithTax,
                     taxCategoryId: variant.taxCategory.id,
                 };
 

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

@@ -18,6 +18,12 @@ export const PRODUCT_VARIANT_FRAGMENT = gql`
         languageCode
         name
         price
+        priceWithTax
+        taxRateApplied {
+            id
+            name
+            value
+        }
         taxCategory {
             id
             name

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


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

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

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

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

+ 12 - 1
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';
@@ -8,6 +8,7 @@ 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,6 +33,16 @@ export class ProductVariant extends VendureEntity implements Translatable, HasCu
     })
     price: number;
 
+    /**
+     * Calculated at run-time
+     */
+    priceWithTax?: number;
+
+    /**
+     * Calculated at run-time
+     */
+    taxRateApplied?: TaxRate;
+
     @ManyToOne(type => TaxCategory)
     taxCategory: TaxCategory;
 

+ 2 - 0
server/src/entity/product-variant/product-variant.graphql

@@ -6,6 +6,8 @@ type ProductVariant implements Node {
     sku: String!
     name: String!
     price: Int!
+    priceWithTax: Int!
+    taxRateApplied: TaxRate
     taxCategory: TaxCategory!
     options: [ProductOption!]!
     facetValues: [FacetValue!]!

+ 8 - 2
server/src/entity/tax-rate/tax-rate.entity.ts

@@ -31,13 +31,19 @@ export class TaxRate extends AdjustmentSource {
     @ManyToOne(type => CustomerGroup, { nullable: true })
     customerGroup?: CustomerGroup;
 
+    /**
+     * Returns the tax applicable to the given price.
+     */
+    getTax(price: number): number {
+        return Math.round(price * (this.value / 100));
+    }
+
     apply(price: number): Adjustment {
-        const tax = Math.round(price * (this.value / 100));
         return {
             type: this.type,
             adjustmentSource: this.getSourceId(),
             description: this.name,
-            amount: tax,
+            amount: this.getTax(price),
         };
     }
 

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

@@ -44,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;
     }
 
     /**

+ 0 - 4
server/src/service/providers/order.service.ts

@@ -168,11 +168,7 @@ export class OrderService {
     }
 
     private async calculateOrderTotals(ctx: RequestContext, order: Order): Promise<Order> {
-        if (!ctx.channel) {
-            throw new I18nError(`error.no-active-channel`);
-        }
         const activeZone = ctx.channel.defaultTaxZone;
-
         const taxRates = await this.connection.getRepository(TaxRate).find({
             where: {
                 enabled: true,

+ 39 - 17
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,22 +45,25 @@ export class ProductService {
             'optionGroups',
             'variants.options',
             'variants.facetValues',
+            'variants.taxCategory',
             'channels',
         ];
 
         return buildListQuery(this.connection, Product, options, relations, ctx.channelId)
             .getManyAndCount()
-            .then(([products, totalItems]) => {
-                const items = products
-                    .map(product =>
-                        translateDeep(product, ctx.languageCode, [
-                            'optionGroups',
-                            'variants',
-                            ['variants', 'options'],
-                            ['variants', 'facetValues'],
-                        ]),
-                    )
-                    .map(product => this.applyChannelPriceToVariants(product, ctx));
+            .then(async ([products, totalItems]) => {
+                const items = await Promise.all(
+                    products
+                        .map(product =>
+                            translateDeep(product, ctx.languageCode, [
+                                'optionGroups',
+                                'variants',
+                                ['variants', 'options'],
+                                ['variants', 'facetValues'],
+                            ]),
+                        )
+                        .map(async product => await this.applyPriceAndTaxToVariants(product, ctx)),
+                );
                 return {
                     items,
                     totalItems,
@@ -86,7 +91,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<Translated<Product>> {
@@ -157,10 +162,27 @@ export class ProductService {
         }
     }
 
-    private applyChannelPriceToVariants<T extends Product>(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 async applyPriceAndTaxToVariants<T extends Product>(product: T, ctx: RequestContext): Promise<T> {
+        const activeTaxRates = await this.taxRateService.getActiveTaxRates();
+        product.variants = product.variants.map(variant => {
+            this.productVariantService.applyChannelPrice(variant, ctx.channelId);
+            const applicableTaxRate = activeTaxRates.find(r =>
+                r.test(ctx.channel.defaultTaxZone, variant.taxCategory),
+            );
+            if (applicableTaxRate) {
+                variant.priceWithTax = variant.price + applicableTaxRate.getTax(variant.price);
+                variant.taxRateApplied = applicableTaxRate;
+            } else {
+                variant.priceWithTax = variant.price;
+            }
+            return variant;
+        });
         return product;
     }
 

+ 22 - 0
server/src/service/providers/tax-rate.service.ts

@@ -15,6 +15,10 @@ 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) {}
@@ -46,6 +50,7 @@ export class TaxRateService {
             );
         }
         const newTaxRate = await this.connection.getRepository(TaxRate).save(taxRate);
+        await this.updateActiveTaxRates();
         return assertFound(this.findOne(newTaxRate.id));
     }
 
@@ -72,6 +77,23 @@ export class TaxRateService {
             );
         }
         await this.connection.getRepository(TaxRate).save(updatedTaxRate);
+        await this.updateActiveTaxRates();
         return assertFound(this.findOne(taxRate.id));
     }
+
+    async getActiveTaxRates(): Promise<TaxRate[]> {
+        if (!this.activeTaxRates.length) {
+            await this.updateActiveTaxRates();
+        }
+        return this.activeTaxRates;
+    }
+
+    private async updateActiveTaxRates() {
+        this.activeTaxRates = await this.connection.getRepository(TaxRate).find({
+            relations: ['category', 'zone', 'customerGroup'],
+            where: {
+                enabled: true,
+            },
+        });
+    }
 }

+ 69 - 43
shared/generated-types.ts

@@ -273,7 +273,7 @@ export interface Order extends Node {
     code: string;
     customer?: Customer | null;
     lines: OrderLine[];
-    adjustments?: Adjustment[] | null;
+    totalPriceBeforeTax: number;
     totalPrice: number;
 }
 
@@ -284,6 +284,7 @@ export interface OrderLine extends Node {
     productVariant: ProductVariant;
     featuredAsset?: Asset | null;
     unitPrice: number;
+    unitPriceWithTax: number;
     quantity: number;
     items: OrderItem[];
     totalPrice: number;
@@ -299,6 +300,8 @@ export interface ProductVariant extends Node {
     sku: string;
     name: string;
     price: number;
+    priceWithTax: number;
+    taxRateApplied?: TaxRate | null;
     taxCategory: TaxCategory;
     options: ProductOption[];
     facetValues: FacetValue[];
@@ -306,6 +309,18 @@ export interface ProductVariant extends Node {
     customFields?: Json | null;
 }
 
+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;
@@ -462,18 +477,6 @@ export interface TaxRateList extends PaginatedList {
     totalItems: number;
 }
 
-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 NetworkStatus {
     inFlightRequests: number;
 }
@@ -2471,7 +2474,7 @@ export namespace OrderResolvers {
         code?: CodeResolver<string, any, Context>;
         customer?: CustomerResolver<Customer | null, any, Context>;
         lines?: LinesResolver<OrderLine[], any, Context>;
-        adjustments?: AdjustmentsResolver<Adjustment[] | null, any, Context>;
+        totalPriceBeforeTax?: TotalPriceBeforeTaxResolver<number, any, Context>;
         totalPrice?: TotalPriceResolver<number, any, Context>;
     }
 
@@ -2485,7 +2488,7 @@ export namespace OrderResolvers {
         Context
     >;
     export type LinesResolver<R = OrderLine[], Parent = any, Context = any> = Resolver<R, Parent, Context>;
-    export type AdjustmentsResolver<R = Adjustment[] | null, Parent = any, Context = any> = Resolver<
+    export type TotalPriceBeforeTaxResolver<R = number, Parent = any, Context = any> = Resolver<
         R,
         Parent,
         Context
@@ -2501,6 +2504,7 @@ export namespace OrderLineResolvers {
         productVariant?: ProductVariantResolver<ProductVariant, any, Context>;
         featuredAsset?: FeaturedAssetResolver<Asset | null, any, Context>;
         unitPrice?: UnitPriceResolver<number, any, Context>;
+        unitPriceWithTax?: UnitPriceWithTaxResolver<number, any, Context>;
         quantity?: QuantityResolver<number, any, Context>;
         items?: ItemsResolver<OrderItem[], any, Context>;
         totalPrice?: TotalPriceResolver<number, any, Context>;
@@ -2522,6 +2526,11 @@ export namespace OrderLineResolvers {
         Context
     >;
     export type UnitPriceResolver<R = number, Parent = any, Context = any> = Resolver<R, Parent, Context>;
+    export type UnitPriceWithTaxResolver<R = number, Parent = any, Context = any> = Resolver<
+        R,
+        Parent,
+        Context
+    >;
     export type QuantityResolver<R = number, Parent = any, Context = any> = Resolver<R, Parent, Context>;
     export type ItemsResolver<R = OrderItem[], Parent = any, Context = any> = Resolver<R, Parent, Context>;
     export type TotalPriceResolver<R = number, Parent = any, Context = any> = Resolver<R, Parent, Context>;
@@ -2542,6 +2551,8 @@ export namespace ProductVariantResolvers {
         sku?: SkuResolver<string, any, Context>;
         name?: NameResolver<string, any, Context>;
         price?: PriceResolver<number, any, Context>;
+        priceWithTax?: PriceWithTaxResolver<number, any, Context>;
+        taxRateApplied?: TaxRateAppliedResolver<TaxRate | null, any, Context>;
         taxCategory?: TaxCategoryResolver<TaxCategory, any, Context>;
         options?: OptionsResolver<ProductOption[], any, Context>;
         facetValues?: FacetValuesResolver<FacetValue[], any, Context>;
@@ -2560,6 +2571,12 @@ export namespace ProductVariantResolvers {
     export type SkuResolver<R = string, Parent = any, Context = any> = Resolver<R, Parent, Context>;
     export type NameResolver<R = string, Parent = any, Context = any> = Resolver<R, Parent, Context>;
     export type PriceResolver<R = number, Parent = any, Context = any> = Resolver<R, Parent, Context>;
+    export type PriceWithTaxResolver<R = number, Parent = any, Context = any> = Resolver<R, Parent, Context>;
+    export type TaxRateAppliedResolver<R = TaxRate | null, Parent = any, Context = any> = Resolver<
+        R,
+        Parent,
+        Context
+    >;
     export type TaxCategoryResolver<R = TaxCategory, Parent = any, Context = any> = Resolver<
         R,
         Parent,
@@ -2587,6 +2604,34 @@ export namespace ProductVariantResolvers {
     >;
 }
 
+export namespace TaxRateResolvers {
+    export interface Resolvers<Context = any> {
+        id?: IdResolver<string, any, Context>;
+        createdAt?: CreatedAtResolver<DateTime, any, Context>;
+        updatedAt?: UpdatedAtResolver<DateTime, any, Context>;
+        name?: NameResolver<string, any, Context>;
+        enabled?: EnabledResolver<boolean, any, Context>;
+        value?: ValueResolver<number, any, Context>;
+        category?: CategoryResolver<TaxCategory, any, Context>;
+        zone?: ZoneResolver<Zone, any, Context>;
+        customerGroup?: CustomerGroupResolver<CustomerGroup | null, any, Context>;
+    }
+
+    export type IdResolver<R = string, Parent = any, Context = any> = Resolver<R, Parent, Context>;
+    export type CreatedAtResolver<R = DateTime, Parent = any, Context = any> = Resolver<R, Parent, Context>;
+    export type UpdatedAtResolver<R = DateTime, Parent = any, Context = any> = Resolver<R, Parent, Context>;
+    export type NameResolver<R = string, Parent = any, Context = any> = Resolver<R, Parent, Context>;
+    export type EnabledResolver<R = boolean, Parent = any, Context = any> = Resolver<R, Parent, Context>;
+    export type ValueResolver<R = number, Parent = any, Context = any> = Resolver<R, Parent, Context>;
+    export type CategoryResolver<R = TaxCategory, Parent = any, Context = any> = Resolver<R, Parent, Context>;
+    export type ZoneResolver<R = Zone, Parent = any, Context = any> = Resolver<R, Parent, Context>;
+    export type CustomerGroupResolver<R = CustomerGroup | null, Parent = any, Context = any> = Resolver<
+        R,
+        Parent,
+        Context
+    >;
+}
+
 export namespace TaxCategoryResolvers {
     export interface Resolvers<Context = any> {
         id?: IdResolver<string, any, Context>;
@@ -3007,34 +3052,6 @@ export namespace TaxRateListResolvers {
     export type TotalItemsResolver<R = number, Parent = any, Context = any> = Resolver<R, Parent, Context>;
 }
 
-export namespace TaxRateResolvers {
-    export interface Resolvers<Context = any> {
-        id?: IdResolver<string, any, Context>;
-        createdAt?: CreatedAtResolver<DateTime, any, Context>;
-        updatedAt?: UpdatedAtResolver<DateTime, any, Context>;
-        name?: NameResolver<string, any, Context>;
-        enabled?: EnabledResolver<boolean, any, Context>;
-        value?: ValueResolver<number, any, Context>;
-        category?: CategoryResolver<TaxCategory, any, Context>;
-        zone?: ZoneResolver<Zone, any, Context>;
-        customerGroup?: CustomerGroupResolver<CustomerGroup | null, any, Context>;
-    }
-
-    export type IdResolver<R = string, Parent = any, Context = any> = Resolver<R, Parent, Context>;
-    export type CreatedAtResolver<R = DateTime, Parent = any, Context = any> = Resolver<R, Parent, Context>;
-    export type UpdatedAtResolver<R = DateTime, Parent = any, Context = any> = Resolver<R, Parent, Context>;
-    export type NameResolver<R = string, Parent = any, Context = any> = Resolver<R, Parent, Context>;
-    export type EnabledResolver<R = boolean, Parent = any, Context = any> = Resolver<R, Parent, Context>;
-    export type ValueResolver<R = number, Parent = any, Context = any> = Resolver<R, Parent, Context>;
-    export type CategoryResolver<R = TaxCategory, Parent = any, Context = any> = Resolver<R, Parent, Context>;
-    export type ZoneResolver<R = Zone, Parent = any, Context = any> = Resolver<R, Parent, Context>;
-    export type CustomerGroupResolver<R = CustomerGroup | null, Parent = any, Context = any> = Resolver<
-        R,
-        Parent,
-        Context
-    >;
-}
-
 export namespace NetworkStatusResolvers {
     export interface Resolvers<Context = any> {
         inFlightRequests?: InFlightRequestsResolver<number, any, Context>;
@@ -4761,6 +4778,8 @@ export namespace ProductVariant {
         languageCode: LanguageCode;
         name: string;
         price: number;
+        priceWithTax: number;
+        taxRateApplied?: TaxRateApplied | null;
         taxCategory: TaxCategory;
         sku: string;
         options: Options[];
@@ -4768,6 +4787,13 @@ export namespace ProductVariant {
         translations: Translations[];
     };
 
+    export type TaxRateApplied = {
+        __typename?: 'TaxRate';
+        id: string;
+        name: string;
+        value: number;
+    };
+
     export type TaxCategory = {
         __typename?: 'TaxCategory';
         id: string;

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