Browse Source

test(server): Refactor and write tests for TaxCalculatorService

Michael Bromley 7 years ago
parent
commit
f4244ef732

+ 7 - 0
server/src/api/common/request-context.ts

@@ -5,6 +5,7 @@ import { DEFAULT_LANGUAGE_CODE } from '../../common/constants';
 import { Channel } from '../../entity/channel/channel.entity';
 import { Channel } from '../../entity/channel/channel.entity';
 import { Session } from '../../entity/session/session.entity';
 import { Session } from '../../entity/session/session.entity';
 import { User } from '../../entity/user/user.entity';
 import { User } from '../../entity/user/user.entity';
+import { Zone } from '../../entity/zone/zone.entity';
 import { I18nError } from '../../i18n/i18n-error';
 import { I18nError } from '../../i18n/i18n-error';
 
 
 /**
 /**
@@ -50,6 +51,12 @@ export class RequestContext {
         return this._session;
         return this._session;
     }
     }
 
 
+    get activeTaxZone(): Zone {
+        // TODO: This will vary depending on Customer data available -
+        // a customer with a billing address in another zone will alter the value etc.
+        return this.channel.defaultTaxZone;
+    }
+
     /**
     /**
      * True if the current session is authorized to access the current resolver method.
      * True if the current session is authorized to access the current resolver method.
      */
      */

+ 2 - 2
server/src/config/default-config.ts

@@ -10,7 +10,7 @@ import { NoAssetStorageStrategy } from './asset-storage-strategy/no-asset-storag
 import { AutoIncrementIdStrategy } from './entity-id-strategy/auto-increment-id-strategy';
 import { AutoIncrementIdStrategy } from './entity-id-strategy/auto-increment-id-strategy';
 import { defaultPromotionActions } from './promotion/default-promotion-actions';
 import { defaultPromotionActions } from './promotion/default-promotion-actions';
 import { defaultPromotionConditions } from './promotion/default-promotion-conditions';
 import { defaultPromotionConditions } from './promotion/default-promotion-conditions';
-import { HalfEvenRoundingStrategy } from './rounding-strategy/half-even-rounding-strategy';
+import { HalfUpRoundingStrategy } from './rounding-strategy/half-up-rounding-strategy';
 import { VendureConfig } from './vendure-config';
 import { VendureConfig } from './vendure-config';
 
 
 /**
 /**
@@ -33,7 +33,7 @@ export const defaultConfig: ReadOnlyRequired<VendureConfig> = {
         sessionDuration: '7d',
         sessionDuration: '7d',
     },
     },
     apiPath: API_PATH,
     apiPath: API_PATH,
-    roundingStrategy: new HalfEvenRoundingStrategy(),
+    roundingStrategy: new HalfUpRoundingStrategy(),
     entityIdStrategy: new AutoIncrementIdStrategy(),
     entityIdStrategy: new AutoIncrementIdStrategy(),
     assetNamingStrategy: new DefaultAssetNamingStrategy(),
     assetNamingStrategy: new DefaultAssetNamingStrategy(),
     assetStorageStrategy: new NoAssetStorageStrategy(),
     assetStorageStrategy: new NoAssetStorageStrategy(),

+ 1 - 7
server/src/service/providers/order.service.ts

@@ -221,13 +221,7 @@ export class OrderService {
                 priceIncludesTax,
                 priceIncludesTax,
                 priceWithTax,
                 priceWithTax,
                 priceWithoutTax,
                 priceWithoutTax,
-            } = this.taxCalculatorService.calculate(
-                line.unitPrice,
-                applicableTaxRate,
-                ctx.channel,
-                activeZone,
-                line.taxCategory,
-            );
+            } = this.taxCalculatorService.calculate(line.unitPrice, line.taxCategory, ctx);
 
 
             line.unitPriceIncludesTax = priceIncludesTax;
             line.unitPriceIncludesTax = priceIncludesTax;
             line.includedTaxRate = applicableTaxRate.value;
             line.includedTaxRate = applicableTaxRate.value;

+ 16 - 24
server/src/service/providers/product-variant.service.ts

@@ -44,10 +44,7 @@ export class ProductVariantService {
             .findOne(productVariantId, { relations })
             .findOne(productVariantId, { relations })
             .then(result => {
             .then(result => {
                 if (result) {
                 if (result) {
-                    return translateDeep(
-                        this.applyChannelPriceAndTax(result, ctx.channel, ctx.channel.defaultTaxZone),
-                        ctx.languageCode,
-                    );
+                    return translateDeep(this.applyChannelPriceAndTax(result, ctx), ctx.languageCode);
                 }
                 }
             });
             });
     }
     }
@@ -93,11 +90,10 @@ export class ProductVariantService {
                 relations: ['options', 'facetValues', 'taxCategory'],
                 relations: ['options', 'facetValues', 'taxCategory'],
             }),
             }),
         );
         );
-        return translateDeep(
-            this.applyChannelPriceAndTax(variant, ctx.channel, ctx.channel.defaultTaxZone),
-            DEFAULT_LANGUAGE_CODE,
-            ['options', 'facetValues'],
-        );
+        return translateDeep(this.applyChannelPriceAndTax(variant, ctx), DEFAULT_LANGUAGE_CODE, [
+            'options',
+            'facetValues',
+        ]);
     }
     }
 
 
     async generateVariantsForProduct(
     async generateVariantsForProduct(
@@ -171,36 +167,32 @@ export class ProductVariantService {
         }
         }
 
 
         return variants.map(v =>
         return variants.map(v =>
-            translateDeep(
-                this.applyChannelPriceAndTax(v, ctx.channel, ctx.channel.defaultTaxZone),
-                DEFAULT_LANGUAGE_CODE,
-                ['options', 'facetValues'],
-            ),
+            translateDeep(this.applyChannelPriceAndTax(v, ctx), DEFAULT_LANGUAGE_CODE, [
+                'options',
+                'facetValues',
+            ]),
         );
         );
     }
     }
 
 
     /**
     /**
      * Populates the `price` field with the price for the specified channel.
      * Populates the `price` field with the price for the specified channel.
      */
      */
-    applyChannelPriceAndTax(variant: ProductVariant, channel: Channel, taxZone: Zone): ProductVariant {
-        const channelPrice = variant.productVariantPrices.find(p => idsAreEqual(p.channelId, channel.id));
+    applyChannelPriceAndTax(variant: ProductVariant, ctx: RequestContext): ProductVariant {
+        const channelPrice = variant.productVariantPrices.find(p => idsAreEqual(p.channelId, ctx.channelId));
         if (!channelPrice) {
         if (!channelPrice) {
             throw new I18nError(`error.no-price-found-for-channel`);
             throw new I18nError(`error.no-price-found-for-channel`);
         }
         }
-        const applicableTaxRate = this.taxRateService.getApplicableTaxRate(taxZone, variant.taxCategory);
+        const applicableTaxRate = this.taxRateService.getApplicableTaxRate(
+            ctx.activeTaxZone,
+            variant.taxCategory,
+        );
 
 
         const {
         const {
             price,
             price,
             priceIncludesTax,
             priceIncludesTax,
             priceWithTax,
             priceWithTax,
             priceWithoutTax,
             priceWithoutTax,
-        } = this.taxCalculatorService.calculate(
-            channelPrice.price,
-            applicableTaxRate,
-            channel,
-            taxZone,
-            variant.taxCategory,
-        );
+        } = this.taxCalculatorService.calculate(channelPrice.price, variant.taxCategory, ctx);
 
 
         variant.price = price;
         variant.price = price;
         variant.priceIncludesTax = priceIncludesTax;
         variant.priceIncludesTax = priceIncludesTax;

+ 1 - 5
server/src/service/providers/product.service.ts

@@ -168,11 +168,7 @@ export class ProductService {
      */
      */
     private applyPriceAndTaxToVariants<T extends Product>(product: T, ctx: RequestContext): T {
     private applyPriceAndTaxToVariants<T extends Product>(product: T, ctx: RequestContext): T {
         product.variants = product.variants.map(variant => {
         product.variants = product.variants.map(variant => {
-            return this.productVariantService.applyChannelPriceAndTax(
-                variant,
-                ctx.channel,
-                ctx.channel.defaultTaxZone,
-            );
+            return this.productVariantService.applyChannelPriceAndTax(variant, ctx);
         });
         });
         return product;
         return product;
     }
     }

+ 239 - 0
server/src/service/providers/tax-calculator.service.spec.ts

@@ -0,0 +1,239 @@
+import { Test } from '@nestjs/testing';
+import { LanguageCode } from 'shared/generated-types';
+import { Connection } from 'typeorm';
+
+import { RequestContext } from '../../api/common/request-context';
+import { Channel } from '../../entity/channel/channel.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 { TaxCalculatorService } from './tax-calculator.service';
+import { TaxRateService } from './tax-rate.service';
+
+describe('TaxCalculatorService', () => {
+    let taxCalculatorService: TaxCalculatorService;
+    const inputPrice = 6543;
+    const taxCategoryStandard = new TaxCategory({
+        id: 'taxCategoryStandard',
+        name: 'Standard Tax',
+    });
+    const taxCategoryReduced = new TaxCategory({
+        id: 'taxCategoryReduced',
+        name: 'Reduced Tax',
+    });
+    const zoneDefault = new Zone({
+        id: 'zoneDefault',
+        name: 'Default Zone',
+    });
+    const zoneOther = new Zone({
+        id: 'zoneOther',
+        name: 'Other Zone',
+    });
+    const zoneWithNoTaxRate = new Zone({
+        id: 'zoneWithNoTaxRate',
+        name: 'Zone for which no TaxRate is configured',
+    });
+    const taxRateDefaultStandard = new TaxRate({
+        id: 'taxRateDefaultStandard',
+        value: 20,
+        enabled: true,
+        zone: zoneDefault,
+        category: taxCategoryStandard,
+    });
+    const taxRateDefaultReduced = new TaxRate({
+        id: 'taxRateDefaultReduced',
+        value: 10,
+        enabled: true,
+        zone: zoneDefault,
+        category: taxCategoryReduced,
+    });
+    const taxRateOtherStandard = new TaxRate({
+        id: 'taxRateOtherStandard',
+        value: 15,
+        enabled: true,
+        zone: zoneOther,
+        category: taxCategoryStandard,
+    });
+    const taxRateOtherReduced = new TaxRate({
+        id: 'taxRateOtherReduced',
+        value: 5,
+        enabled: true,
+        zone: zoneOther,
+        category: taxCategoryReduced,
+    });
+
+    class MockConnection {
+        getRepository() {
+            return {
+                find() {
+                    return Promise.resolve([
+                        taxRateDefaultStandard,
+                        taxRateDefaultReduced,
+                        taxRateOtherStandard,
+                        taxRateOtherReduced,
+                    ]);
+                },
+            };
+        }
+    }
+
+    function createRequestContext(pricesIncludeTax: boolean, activeTaxZone: Zone): RequestContext {
+        const channel = new Channel({
+            defaultTaxZone: zoneDefault,
+            pricesIncludeTax,
+        });
+        const ctx = new RequestContext({
+            channel,
+            authorizedAsOwnerOnly: false,
+            languageCode: LanguageCode.en,
+            isAuthorized: true,
+            session: {} as any,
+        });
+        // TODO: Hack until we implement the other ways of
+        // calculating the activeTaxZone (customer billing address etc)
+        delete Object.getPrototypeOf(ctx).activeTaxZone;
+        (ctx as any).activeTaxZone = activeTaxZone;
+        return ctx;
+    }
+
+    beforeEach(async () => {
+        const module = await Test.createTestingModule({
+            providers: [
+                TaxCalculatorService,
+                TaxRateService,
+                { provide: Connection, useClass: MockConnection },
+            ],
+        }).compile();
+
+        taxCalculatorService = module.get(TaxCalculatorService);
+        const taxRateService = module.get(TaxRateService);
+        await taxRateService.initTaxRates();
+    });
+
+    describe('with prices which do not include tax', () => {
+        it('standard tax, default zone', () => {
+            const ctx = createRequestContext(false, zoneDefault);
+            const result = taxCalculatorService.calculate(inputPrice, taxCategoryStandard, ctx);
+
+            expect(result).toEqual({
+                price: inputPrice,
+                priceIncludesTax: false,
+                priceWithTax: taxRateDefaultStandard.grossPriceOf(inputPrice),
+                priceWithoutTax: inputPrice,
+            });
+        });
+
+        it('reduced tax, default zone', () => {
+            const ctx = createRequestContext(false, zoneDefault);
+            const result = taxCalculatorService.calculate(6543, taxCategoryReduced, ctx);
+
+            expect(result).toEqual({
+                price: inputPrice,
+                priceIncludesTax: false,
+                priceWithTax: taxRateDefaultReduced.grossPriceOf(inputPrice),
+                priceWithoutTax: inputPrice,
+            });
+        });
+
+        it('standard tax, other zone', () => {
+            const ctx = createRequestContext(false, zoneOther);
+            const result = taxCalculatorService.calculate(6543, taxCategoryStandard, ctx);
+
+            expect(result).toEqual({
+                price: inputPrice,
+                priceIncludesTax: false,
+                priceWithTax: taxRateOtherStandard.grossPriceOf(inputPrice),
+                priceWithoutTax: inputPrice,
+            });
+        });
+
+        it('reduced tax, other zone', () => {
+            const ctx = createRequestContext(false, zoneOther);
+            const result = taxCalculatorService.calculate(inputPrice, taxCategoryReduced, ctx);
+
+            expect(result).toEqual({
+                price: inputPrice,
+                priceIncludesTax: false,
+                priceWithTax: taxRateOtherReduced.grossPriceOf(inputPrice),
+                priceWithoutTax: inputPrice,
+            });
+        });
+
+        it('standard tax, unconfigured zone', () => {
+            const ctx = createRequestContext(false, zoneWithNoTaxRate);
+            const result = taxCalculatorService.calculate(inputPrice, taxCategoryReduced, ctx);
+
+            expect(result).toEqual({
+                price: inputPrice,
+                priceIncludesTax: false,
+                priceWithTax: inputPrice,
+                priceWithoutTax: inputPrice,
+            });
+        });
+    });
+
+    describe('with prices which include tax', () => {
+        it('standard tax, default zone', () => {
+            const ctx = createRequestContext(true, zoneDefault);
+            const result = taxCalculatorService.calculate(inputPrice, taxCategoryStandard, ctx);
+
+            expect(result).toEqual({
+                price: inputPrice,
+                priceIncludesTax: true,
+                priceWithTax: inputPrice,
+                priceWithoutTax: taxRateDefaultStandard.netPriceOf(inputPrice),
+            });
+        });
+
+        it('reduced tax, default zone', () => {
+            const ctx = createRequestContext(true, zoneDefault);
+            const result = taxCalculatorService.calculate(inputPrice, taxCategoryReduced, ctx);
+
+            expect(result).toEqual({
+                price: inputPrice,
+                priceIncludesTax: true,
+                priceWithTax: inputPrice,
+                priceWithoutTax: taxRateDefaultReduced.netPriceOf(inputPrice),
+            });
+        });
+
+        it('standard tax, other zone', () => {
+            const ctx = createRequestContext(true, zoneOther);
+            const result = taxCalculatorService.calculate(inputPrice, taxCategoryStandard, ctx);
+
+            expect(result).toEqual({
+                price: taxRateDefaultStandard.netPriceOf(inputPrice),
+                priceIncludesTax: false,
+                priceWithTax: taxRateOtherStandard.grossPriceOf(
+                    taxRateDefaultStandard.netPriceOf(inputPrice),
+                ),
+                priceWithoutTax: taxRateDefaultStandard.netPriceOf(inputPrice),
+            });
+        });
+
+        it('reduced tax, other zone', () => {
+            const ctx = createRequestContext(true, zoneOther);
+            const result = taxCalculatorService.calculate(inputPrice, taxCategoryReduced, ctx);
+
+            expect(result).toEqual({
+                price: taxRateDefaultReduced.netPriceOf(inputPrice),
+                priceIncludesTax: false,
+                priceWithTax: taxRateOtherReduced.grossPriceOf(taxRateDefaultReduced.netPriceOf(inputPrice)),
+                priceWithoutTax: taxRateDefaultReduced.netPriceOf(inputPrice),
+            });
+        });
+
+        it('standard tax, unconfigured zone', () => {
+            const ctx = createRequestContext(true, zoneWithNoTaxRate);
+            const result = taxCalculatorService.calculate(inputPrice, taxCategoryStandard, ctx);
+
+            expect(result).toEqual({
+                price: taxRateDefaultStandard.netPriceOf(inputPrice),
+                priceIncludesTax: false,
+                priceWithTax: taxRateDefaultStandard.netPriceOf(inputPrice),
+                priceWithoutTax: taxRateDefaultStandard.netPriceOf(inputPrice),
+            });
+        });
+    });
+});

+ 13 - 11
server/src/service/providers/tax-calculator.service.ts

@@ -1,5 +1,7 @@
 import { Injectable } from '@nestjs/common';
 import { Injectable } from '@nestjs/common';
 
 
+import { RequestContext } from '../../api/common/request-context';
+import { idsAreEqual } from '../../common/utils';
 import { Channel } from '../../entity/channel/channel.entity';
 import { Channel } from '../../entity/channel/channel.entity';
 import { TaxCategory } from '../../entity/tax-category/tax-category.entity';
 import { TaxCategory } from '../../entity/tax-category/tax-category.entity';
 import { TaxRate } from '../../entity/tax-rate/tax-rate.entity';
 import { TaxRate } from '../../entity/tax-rate/tax-rate.entity';
@@ -18,26 +20,25 @@ export interface TaxCalculationResult {
 export class TaxCalculatorService {
 export class TaxCalculatorService {
     constructor(private taxRateService: TaxRateService) {}
     constructor(private taxRateService: TaxRateService) {}
 
 
-    calculate(
-        inputPrice: number,
-        taxRate: TaxRate,
-        channel: Channel,
-        zone: Zone,
-        taxCategory: TaxCategory,
-    ): TaxCalculationResult {
+    /**
+     * Given a price and TacxCategory, this method calculates the applicable tax rate and returns the adjusted
+     * price along with other contextual information.
+     */
+    calculate(inputPrice: number, taxCategory: TaxCategory, ctx: RequestContext): TaxCalculationResult {
         let price = 0;
         let price = 0;
         let priceWithTax = 0;
         let priceWithTax = 0;
         let priceWithoutTax = 0;
         let priceWithoutTax = 0;
         let priceIncludesTax = false;
         let priceIncludesTax = false;
+        const taxRate = this.taxRateService.getApplicableTaxRate(ctx.activeTaxZone, taxCategory);
 
 
-        if (channel.pricesIncludeTax) {
-            const isDefaultZone = zone.id === channel.defaultTaxZone.id;
+        if (ctx.channel.pricesIncludeTax) {
+            const isDefaultZone = idsAreEqual(ctx.activeTaxZone.id, ctx.channel.defaultTaxZone.id);
             const taxRateForDefaultZone = this.taxRateService.getApplicableTaxRate(
             const taxRateForDefaultZone = this.taxRateService.getApplicableTaxRate(
-                channel.defaultTaxZone,
+                ctx.channel.defaultTaxZone,
                 taxCategory,
                 taxCategory,
             );
             );
-
             priceWithoutTax = taxRateForDefaultZone.netPriceOf(inputPrice);
             priceWithoutTax = taxRateForDefaultZone.netPriceOf(inputPrice);
+
             if (isDefaultZone) {
             if (isDefaultZone) {
                 priceIncludesTax = true;
                 priceIncludesTax = true;
                 price = inputPrice;
                 price = inputPrice;
@@ -50,6 +51,7 @@ export class TaxCalculatorService {
             const netPrice = inputPrice;
             const netPrice = inputPrice;
             price = netPrice;
             price = netPrice;
             priceWithTax = netPrice + taxRate.taxPayableOn(netPrice);
             priceWithTax = netPrice + taxRate.taxPayableOn(netPrice);
+            priceWithoutTax = netPrice;
         }
         }
 
 
         return {
         return {