Skip to content

Commit

Permalink
feat(server): Make tax zone/price calculations configurable
Browse files Browse the repository at this point in the history
Relates to #31
  • Loading branch information
michaelbromley committed Jan 8, 2019
1 parent fe05f44 commit 52ecc37
Show file tree
Hide file tree
Showing 19 changed files with 270 additions and 114 deletions.
9 changes: 8 additions & 1 deletion server/e2e/product-category.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
LanguageCode,
MoveProductCategory,
ProductCategory,
SortOrder,
UpdateProductCategory,
} from '../../shared/generated-types';
import { ROOT_CATEGORY_NAME } from '../../shared/shared-constants';
Expand All @@ -36,7 +37,13 @@ describe('ProductCategory resolver', () => {
customerCount: 1,
});
await client.init();
const assetsResult = await client.query<GetAssetList.Query, GetAssetList.Variables>(GET_ASSET_LIST);
const assetsResult = await client.query<GetAssetList.Query, GetAssetList.Variables>(GET_ASSET_LIST, {
options: {
sort: {
name: SortOrder.ASC,
},
},
});
assets = assetsResult.assets.items;
}, TEST_SETUP_TIMEOUT_MS);

Expand Down
47 changes: 32 additions & 15 deletions server/e2e/product.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,21 +430,6 @@ describe('Product resolver', () => {
describe('variants', () => {
let variants: ProductWithVariants.Variants[];

it('generateVariantsForProduct generates variants', async () => {
const result = await client.query<
GenerateProductVariants.Mutation,
GenerateProductVariants.Variables
>(GENERATE_PRODUCT_VARIANTS, {
productId: newProduct.id,
defaultPrice: 123,
defaultSku: 'ABC',
});
variants = result.generateVariantsForProduct.variants;
expect(variants.length).toBe(2);
expect(variants[0].options.length).toBe(1);
expect(variants[1].options.length).toBe(1);
});

it('generateVariantsForProduct throws with an invalid productId', async () => {
try {
await client.query<GenerateProductVariants.Mutation, GenerateProductVariants.Variables>(
Expand All @@ -461,6 +446,38 @@ describe('Product resolver', () => {
}
});

it('generateVariantsForProduct throws with an invalid defaultTaxCategoryId', async () => {
try {
await client.query<GenerateProductVariants.Mutation, GenerateProductVariants.Variables>(
GENERATE_PRODUCT_VARIANTS,
{
productId: newProduct.id,
defaultTaxCategoryId: '999',
},
);
fail('Should have thrown');
} catch (err) {
expect(err.message).toEqual(
expect.stringContaining(`No TaxCategory with the id '999' could be found`),
);
}
});

it('generateVariantsForProduct generates variants', async () => {
const result = await client.query<
GenerateProductVariants.Mutation,
GenerateProductVariants.Variables
>(GENERATE_PRODUCT_VARIANTS, {
productId: newProduct.id,
defaultPrice: 123,
defaultSku: 'ABC',
});
variants = result.generateVariantsForProduct.variants;
expect(variants.length).toBe(2);
expect(variants[0].options.length).toBe(1);
expect(variants[1].options.length).toBe(1);
});

it('updateProductVariants updates variants', async () => {
const firstVariant = variants[0];
const result = await client.query<
Expand Down
6 changes: 0 additions & 6 deletions server/src/api/common/request-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,6 @@ export class RequestContext {
}
}

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.
*/
Expand Down
2 changes: 1 addition & 1 deletion server/src/api/resolvers/zone.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export class ZoneResolver {

@Query()
@Allow(Permission.ReadSettings)
zones(@Ctx() ctx: RequestContext): Promise<Zone[]> {
zones(@Ctx() ctx: RequestContext): Zone[] {
return this.zoneService.findAll(ctx);
}

Expand Down
1 change: 1 addition & 0 deletions server/src/config/config.service.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export class MockConfigService implements MockClass<ConfigService> {
promotionActions: [],
};
paymentOptions: {};
taxOptions: {};
emailOptions: {};
importExportOptions: {};
orderMergeOptions = {};
Expand Down
5 changes: 5 additions & 0 deletions server/src/config/config.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
PaymentOptions,
PromotionOptions,
ShippingOptions,
TaxOptions,
VendureConfig,
} from './vendure-config';
import { VendurePlugin } from './vendure-plugin/vendure-plugin';
Expand Down Expand Up @@ -97,6 +98,10 @@ export class ConfigService implements VendureConfig {
return this.activeConfig.paymentOptions;
}

get taxOptions(): TaxOptions {
return this.activeConfig.taxOptions;
}

get emailOptions(): Required<EmailOptions<any>> {
return this.activeConfig.emailOptions as Required<EmailOptions<any>>;
}
Expand Down
6 changes: 6 additions & 0 deletions server/src/config/default-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { defaultPromotionActions } from './promotion/default-promotion-actions';
import { defaultPromotionConditions } from './promotion/default-promotion-conditions';
import { defaultShippingCalculator } from './shipping-method/default-shipping-calculator';
import { defaultShippingEligibilityChecker } from './shipping-method/default-shipping-eligibility-checker';
import { DefaultTaxCalculationStrategy } from './tax/default-tax-calculation-strategy';
import { DefaultTaxZoneStrategy } from './tax/default-tax-zone-strategy';
import { VendureConfig } from './vendure-config';

/**
Expand Down Expand Up @@ -66,6 +68,10 @@ export const defaultConfig: ReadOnlyRequired<VendureConfig> = {
paymentOptions: {
paymentMethodHandlers: [],
},
taxOptions: {
taxZoneStrategy: new DefaultTaxZoneStrategy(),
taxCalculationStrategy: new DefaultTaxCalculationStrategy(),
},
emailOptions: {
emailTemplatePath: __dirname,
emailTypes: {},
Expand Down
48 changes: 48 additions & 0 deletions server/src/config/tax/default-tax-calculation-strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { RequestContext } from '../../api/common/request-context';
import { idsAreEqual } from '../../common/utils';
import { TaxCategory } from '../../entity';
import { TaxCalculationResult } from '../../service/helpers/tax-calculator/tax-calculator';
import { TaxRateService } from '../../service/services/tax-rate.service';

import { TaxCalculationArgs, TaxCalculationStrategy } from './tax-calculation-strategy';

export class DefaultTaxCalculationStrategy implements TaxCalculationStrategy {
calculate(args: TaxCalculationArgs): TaxCalculationResult {
const { inputPrice, activeTaxZone, ctx, taxCategory, taxRateService } = args;
let price = 0;
let priceWithTax = 0;
let priceWithoutTax = 0;
let priceIncludesTax = false;
const taxRate = taxRateService.getApplicableTaxRate(activeTaxZone, taxCategory);

if (ctx.channel.pricesIncludeTax) {
const isDefaultZone = idsAreEqual(activeTaxZone.id, ctx.channel.defaultTaxZone.id);
const taxRateForDefaultZone = taxRateService.getApplicableTaxRate(
ctx.channel.defaultTaxZone,
taxCategory,
);
priceWithoutTax = taxRateForDefaultZone.netPriceOf(inputPrice);

if (isDefaultZone) {
priceIncludesTax = true;
price = inputPrice;
priceWithTax = inputPrice;
} else {
price = priceWithoutTax;
priceWithTax = taxRate.grossPriceOf(priceWithoutTax);
}
} else {
const netPrice = inputPrice;
price = netPrice;
priceWithTax = netPrice + taxRate.taxPayableOn(netPrice);
priceWithoutTax = netPrice;
}

return {
price,
priceIncludesTax,
priceWithTax,
priceWithoutTax,
};
}
}
9 changes: 9 additions & 0 deletions server/src/config/tax/default-tax-zone-strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Channel, Order, Zone } from '../../entity';

import { TaxZoneStrategy } from './tax-zone-strategy';

export class DefaultTaxZoneStrategy implements TaxZoneStrategy {
determineTaxZone(zones: Zone[], channel: Channel, order?: Order): Zone {
return channel.defaultTaxZone;
}
}
19 changes: 19 additions & 0 deletions server/src/config/tax/tax-calculation-strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { RequestContext } from '../../api/common/request-context';
import { TaxCategory, Zone } from '../../entity';
import { TaxCalculationResult } from '../../service/helpers/tax-calculator/tax-calculator';
import { TaxRateService } from '../../service/services/tax-rate.service';

export interface TaxCalculationArgs {
inputPrice: number;
taxCategory: TaxCategory;
activeTaxZone: Zone;
ctx: RequestContext;
taxRateService: TaxRateService;
}

/**
* Defines how taxes are calculated based on the input price, tax zone and current request context.
*/
export interface TaxCalculationStrategy {
calculate(args: TaxCalculationArgs): TaxCalculationResult;
}
8 changes: 8 additions & 0 deletions server/src/config/tax/tax-zone-strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Channel, Order, Zone } from '../../entity';

/**
* Defines how the active Zone is determined for the purposes of calculating taxes.
*/
export interface TaxZoneStrategy {
determineTaxZone(zones: Zone[], channel: Channel, order?: Order): Zone;
}
17 changes: 17 additions & 0 deletions server/src/config/vendure-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import { PromotionAction } from './promotion/promotion-action';
import { PromotionCondition } from './promotion/promotion-condition';
import { ShippingCalculator } from './shipping-method/shipping-calculator';
import { ShippingEligibilityChecker } from './shipping-method/shipping-eligibility-checker';
import { TaxCalculationStrategy } from './tax/tax-calculation-strategy';
import { TaxZoneStrategy } from './tax/tax-zone-strategy';
import { VendurePlugin } from './vendure-plugin/vendure-plugin';

export interface AuthOptions {
Expand Down Expand Up @@ -183,6 +185,17 @@ export interface PaymentOptions {
paymentMethodHandlers: Array<PaymentMethodHandler<any>>;
}

export interface TaxOptions {
/**
* Defines the strategy used to determine the applicable Zone used in tax calculations.
*/
taxZoneStrategy: TaxZoneStrategy;
/**
* Defines the strategy used for calculating taxes.
*/
taxCalculationStrategy: TaxCalculationStrategy;
}

export interface ImportExportOptions {
/**
* The directory in which assets to be imported are located.
Expand Down Expand Up @@ -270,6 +283,10 @@ export interface VendureConfig {
* Configures available payment processing methods.
*/
paymentOptions: PaymentOptions;
/**
* Configures how taxes are calculated on products.
*/
taxOptions?: TaxOptions;
/**
* Configures the handling of transactional emails.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,23 @@ import { Test } from '@nestjs/testing';
import { Connection } from 'typeorm';

import { Omit } from '../../../../../shared/omit';
import { ConfigService } from '../../../config/config.service';
import { MockConfigService } from '../../../config/config.service.mock';
import { DefaultTaxCalculationStrategy } from '../../../config/tax/default-tax-calculation-strategy';
import { DefaultTaxZoneStrategy } from '../../../config/tax/default-tax-zone-strategy';
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 { TaxCategory } from '../../../entity/tax-category/tax-category.entity';
import { TaxRateService } from '../../services/tax-rate.service';
import { ZoneService } from '../../services/zone.service';
import { ListQueryBuilder } from '../list-query-builder/list-query-builder';
import { ShippingCalculator } from '../shipping-calculator/shipping-calculator';
import { TaxCalculator } from '../tax-calculator/tax-calculator';
import {
createRequestContext,
MockConnection,
taxCategoryStandard,
zoneDefault,
} from '../tax-calculator/tax-calculator-test-fixtures';

import { OrderCalculator } from './order-calculator';
Expand All @@ -31,10 +35,17 @@ describe('OrderCalculator', () => {
{ provide: ShippingCalculator, useValue: { getEligibleShippingMethods: () => [] } },
{ provide: Connection, useClass: MockConnection },
{ provide: ListQueryBuilder, useValue: {} },
{ provide: ConfigService, useClass: MockConfigService },
{ provide: ZoneService, useValue: { findAll: () => [] } },
],
}).compile();

orderCalculator = module.get(OrderCalculator);
const mockConfigService = module.get<ConfigService, MockConfigService>(ConfigService);
mockConfigService.taxOptions = {
taxZoneStrategy: new DefaultTaxZoneStrategy(),
taxCalculationStrategy: new DefaultTaxCalculationStrategy(),
};
const taxRateService = module.get(TaxRateService);
await taxRateService.initTaxRates();
});
Expand Down Expand Up @@ -64,7 +75,7 @@ describe('OrderCalculator', () => {

describe('taxes only', () => {
it('single line with taxes not included', async () => {
const ctx = createRequestContext(false, zoneDefault);
const ctx = createRequestContext(false);
const order = createOrder({
lines: [{ unitPrice: 123, taxCategory: taxCategoryStandard, quantity: 1 }],
});
Expand All @@ -75,7 +86,7 @@ describe('OrderCalculator', () => {
});

it('single line with taxes not included, multiple items', async () => {
const ctx = createRequestContext(false, zoneDefault);
const ctx = createRequestContext(false);
const order = createOrder({
lines: [{ unitPrice: 123, taxCategory: taxCategoryStandard, quantity: 3 }],
});
Expand All @@ -86,7 +97,7 @@ describe('OrderCalculator', () => {
});

it('single line with taxes included', async () => {
const ctx = createRequestContext(true, zoneDefault);
const ctx = createRequestContext(true);
const order = createOrder({
lines: [{ unitPrice: 123, taxCategory: taxCategoryStandard, quantity: 1 }],
});
Expand All @@ -97,7 +108,7 @@ describe('OrderCalculator', () => {
});

it('resets totals when lines array is empty', async () => {
const ctx = createRequestContext(true, zoneDefault);
const ctx = createRequestContext(true);
const order = createOrder({
lines: [],
subTotal: 148,
Expand Down
Loading

0 comments on commit 52ecc37

Please sign in to comment.