Browse Source

feat(server): Correctly calculate taxes for OrderLines

When the ProductVariant has tax-inclusive prices. Relates to #31
Michael Bromley 7 years ago
parent
commit
ff31e03c96

+ 19 - 4
server/src/entity/order-line/order-line.entity.ts

@@ -27,6 +27,10 @@ export class OrderLine extends VendureEntity {
 
     @Column() unitPrice: number;
 
+    @Column() unitPriceIncludesTax: boolean;
+
+    @Column() includedTaxRate: number;
+
     @OneToMany(type => OrderItem, item => item.line)
     items: OrderItem[];
 
@@ -43,7 +47,11 @@ export class OrderLine extends VendureEntity {
 
     @Calculated()
     get unitPriceWithTax(): number {
-        return this.unitPriceWithPromotions + this.unitTax;
+        if (this.unitPriceIncludesTax) {
+            return this.unitPriceWithPromotions;
+        } else {
+            return this.unitPriceWithPromotions + this.unitTax;
+        }
     }
 
     @Calculated()
@@ -53,7 +61,7 @@ export class OrderLine extends VendureEntity {
 
     @Calculated()
     get totalPrice(): number {
-        return (this.unitPriceWithPromotions + this.unitTax) * this.quantity;
+        return this.unitPriceWithTax * this.quantity;
     }
 
     @Calculated()
@@ -65,8 +73,15 @@ export class OrderLine extends VendureEntity {
     }
 
     get unitTax(): number {
-        const taxAdjustment = this.adjustments.find(a => a.type === AdjustmentType.TAX);
-        return taxAdjustment ? taxAdjustment.amount : 0;
+        if (this.unitPriceIncludesTax) {
+            return Math.round(
+                this.unitPriceWithPromotions -
+                    this.unitPriceWithPromotions / ((100 + this.includedTaxRate) / 100),
+            );
+        } else {
+            const taxAdjustment = this.adjustments.find(a => a.type === AdjustmentType.TAX);
+            return taxAdjustment ? taxAdjustment.amount : 0;
+        }
     }
 
     /**

+ 14 - 0
server/src/entity/tax-rate/tax-rate.entity.ts

@@ -41,6 +41,13 @@ export class TaxRate extends AdjustmentSource {
         return this.round(grossPrice - grossPrice / ((100 + this.value) / 100));
     }
 
+    /**
+     * Given a gross (tax-inclusive) price, returns the net price.
+     */
+    netPriceOf(grossPrice: number): number {
+        return grossPrice - this.taxComponentOf(grossPrice);
+    }
+
     /**
      * Returns the tax applicable to the given net price.
      */
@@ -48,6 +55,13 @@ export class TaxRate extends AdjustmentSource {
         return this.round(netPrice * (this.value / 100));
     }
 
+    /**
+     * Given a net price, return the gross price (net + tax)
+     */
+    grossPriceOf(netPrice: number): number {
+        return netPrice + this.taxPayableOn(netPrice);
+    }
+
     apply(price: number): Adjustment {
         return {
             type: this.type,

+ 48 - 18
server/src/service/providers/order.service.ts

@@ -19,11 +19,15 @@ import { buildListQuery } from '../helpers/build-list-query';
 import { translateDeep } from '../helpers/translate-entity';
 
 import { ProductVariantService } from './product-variant.service';
+import { TaxCalculatorService } from './tax-calculator.service';
+import { TaxRateService } from './tax-rate.service';
 
 export class OrderService {
     constructor(
         @InjectConnection() private connection: Connection,
         private productVariantService: ProductVariantService,
+        private taxRateService: TaxRateService,
+        private taxCalculatorService: TaxCalculatorService,
     ) {}
 
     findAll(ctx: RequestContext, options?: ListQueryOptions<Order>): Promise<PaginatedList<Order>> {
@@ -77,12 +81,7 @@ export class OrderService {
         let orderLine = order.lines.find(line => idsAreEqual(line.productVariant.id, productVariantId));
 
         if (!orderLine) {
-            const newLine = new OrderLine({
-                productVariant,
-                taxCategory: productVariant.taxCategory,
-                featuredAsset: productVariant.product.featuredAsset,
-                unitPrice: productVariant.price,
-            });
+            const newLine = this.createOrderLineFromVariant(productVariant);
             orderLine = await this.connection.getRepository(OrderLine).save(newLine);
             order.lines.push(orderLine);
             await this.connection.getRepository(Order).save(order);
@@ -158,6 +157,17 @@ export class OrderService {
         return orderItem;
     }
 
+    private createOrderLineFromVariant(productVariant: ProductVariant): OrderLine {
+        return new OrderLine({
+            productVariant,
+            taxCategory: productVariant.taxCategory,
+            featuredAsset: productVariant.product.featuredAsset,
+            unitPrice: productVariant.price,
+            unitPriceIncludesTax: productVariant.priceIncludesTax,
+            includedTaxRate: productVariant.priceIncludesTax ? productVariant.taxRateApplied.value : 0,
+        });
+    }
+
     /**
      * Throws if quantity is negative.
      */
@@ -180,30 +190,50 @@ export class OrderService {
         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);
+        if (order.lines.length) {
+            // First apply taxes to the non-discounted prices
+            this.applyTaxes(order, taxRates, activeZone, ctx);
+            // 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, ctx);
+        } else {
+            this.calculateOrderTotals(order);
+        }
 
         await this.connection.getRepository(Order).save(order);
         await this.connection.getRepository(OrderItem).save(order.getOrderItems());
+        await this.connection.getRepository(OrderLine).save(order.lines);
         return order;
     }
 
     /**
      * Applies the correct TaxRate to each OrderItem in the order.
      */
-    private applyTaxes(order: Order, taxRates: TaxRate[], activeZone: Zone) {
+    private applyTaxes(order: Order, taxRates: TaxRate[], activeZone: Zone, ctx: RequestContext) {
         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) {
+            const applicableTaxRate = this.taxRateService.getApplicableTaxRate(activeZone, line.taxCategory);
+            const {
+                price,
+                priceIncludesTax,
+                priceWithTax,
+                priceWithoutTax,
+            } = this.taxCalculatorService.calculate(
+                line.unitPrice,
+                applicableTaxRate,
+                ctx.channel,
+                activeZone,
+                line.taxCategory,
+            );
+
+            line.unitPriceIncludesTax = priceIncludesTax;
+            line.includedTaxRate = applicableTaxRate.value;
+
+            if (!priceIncludesTax) {
+                for (const item of line.items) {
                     item.pendingAdjustments = item.pendingAdjustments.concat(
                         applicableTaxRate.apply(line.unitPriceWithPromotions),
                     );

+ 18 - 25
server/src/service/providers/product-variant.service.ts

@@ -23,6 +23,7 @@ import { translateDeep } from '../helpers/translate-entity';
 import { TranslationUpdaterService } from '../helpers/translation-updater.service';
 import { updateTranslatable } from '../helpers/update-translatable';
 
+import { TaxCalculatorService } from './tax-calculator.service';
 import { TaxCategoryService } from './tax-category.service';
 import { TaxRateService } from './tax-rate.service';
 
@@ -32,6 +33,7 @@ export class ProductVariantService {
         @InjectConnection() private connection: Connection,
         private taxCategoryService: TaxCategoryService,
         private taxRateService: TaxRateService,
+        private taxCalculatorService: TaxCalculatorService,
         private translationUpdaterService: TranslationUpdaterService,
     ) {}
 
@@ -187,31 +189,22 @@ export class ProductVariantService {
         }
         const applicableTaxRate = this.taxRateService.getApplicableTaxRate(taxZone, variant.taxCategory);
 
-        if (channel.pricesIncludeTax) {
-            const isDefaultZone = taxZone.id === channel.defaultTaxZone.id;
-            if (isDefaultZone) {
-                const grossPrice = channelPrice.price;
-                variant.priceIncludesTax = true;
-                variant.price = grossPrice;
-                variant.priceWithTax = grossPrice;
-            } else {
-                const taxRateForDefaultZone = this.taxRateService.getApplicableTaxRate(
-                    channel.defaultTaxZone,
-                    variant.taxCategory,
-                );
-                const grossPriceInDefaultZone = channelPrice.price;
-                const netPrice =
-                    grossPriceInDefaultZone - taxRateForDefaultZone.taxComponentOf(grossPriceInDefaultZone);
-                variant.price = netPrice;
-                variant.priceIncludesTax = false;
-                variant.priceWithTax = netPrice + applicableTaxRate.taxPayableOn(netPrice);
-            }
-        } else {
-            const netPrice = channelPrice.price;
-            variant.price = netPrice;
-            variant.priceIncludesTax = false;
-            variant.priceWithTax = netPrice + applicableTaxRate.taxPayableOn(netPrice);
-        }
+        const {
+            price,
+            priceIncludesTax,
+            priceWithTax,
+            priceWithoutTax,
+        } = this.taxCalculatorService.calculate(
+            channelPrice.price,
+            applicableTaxRate,
+            channel,
+            taxZone,
+            variant.taxCategory,
+        );
+
+        variant.price = price;
+        variant.priceIncludesTax = priceIncludesTax;
+        variant.priceWithTax = priceWithTax;
         variant.taxRateApplied = applicableTaxRate;
         return variant;
     }

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

@@ -0,0 +1,62 @@
+import { Injectable } from '@nestjs/common';
+
+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 { TaxRateService } from './tax-rate.service';
+
+export interface TaxCalculationResult {
+    price: number;
+    priceIncludesTax: boolean;
+    priceWithoutTax: number;
+    priceWithTax: number;
+}
+
+@Injectable()
+export class TaxCalculatorService {
+    constructor(private taxRateService: TaxRateService) {}
+
+    calculate(
+        inputPrice: number,
+        taxRate: TaxRate,
+        channel: Channel,
+        zone: Zone,
+        taxCategory: TaxCategory,
+    ): TaxCalculationResult {
+        let price = 0;
+        let priceWithTax = 0;
+        let priceWithoutTax = 0;
+        let priceIncludesTax = false;
+
+        if (channel.pricesIncludeTax) {
+            const isDefaultZone = zone.id === channel.defaultTaxZone.id;
+            const taxRateForDefaultZone = this.taxRateService.getApplicableTaxRate(
+                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);
+        }
+
+        return {
+            price,
+            priceIncludesTax,
+            priceWithTax,
+            priceWithoutTax,
+        };
+    }
+}

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

@@ -22,6 +22,7 @@ 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 { TaxCalculatorService } from './providers/tax-calculator.service';
 import { TaxCategoryService } from './providers/tax-category.service';
 import { TaxRateService } from './providers/tax-rate.service';
 import { ZoneService } from './providers/zone.service';
@@ -57,7 +58,7 @@ const exportedProviders = [
  */
 @Module({
     imports: [ConfigModule, TypeOrmModule.forRoot(getConfig().dbConnectionOptions)],
-    providers: [...exportedProviders, PasswordService, TranslationUpdaterService],
+    providers: [...exportedProviders, PasswordService, TranslationUpdaterService, TaxCalculatorService],
     exports: exportedProviders,
 })
 export class ServiceModule implements OnModuleInit {