Sfoglia il codice sorgente

feat(core): OrderItem.unitPrice now _always_ excludes tax

Relates to #467. This change means that storefronts do not need to do any tax calculations and can
reliably list `unitPrice` and `unitPriceWithTax` and know that the actual net/gross prices
are being listed.

BREAKING CHANGE: The `OrderItem.unitPrice` is now _always_ given as the net (without tax) price
of the related ProductVariant. Formerly, it was either the net or gross price, depending on
the `pricesIncludeTax` setting of the Channel. If you have existing Orders where
`unitPriceIncludesTax = true`, you will need to manually update the `unitPrice` value *before*
running any other migrations for this release. The query will look like:

    `UPDATE order_item SET unitPrice = ROUND(unitPrice / ((taxRate + 100) / 100)) WHERE unitPriceIncludesTax = 1`
Michael Bromley 5 anni fa
parent
commit
6e2d4907b7

+ 1 - 1
packages/core/e2e/shipping-method.e2e-spec.ts

@@ -34,7 +34,7 @@ const calculatorWithMetadata = new ShippingCalculator({
     code: 'calculator-with-metadata',
     description: [{ languageCode: LanguageCode.en, value: 'Has metadata' }],
     args: {},
-    calculate: order => {
+    calculate: () => {
         return {
             price: 100,
             priceWithTax: 100,

+ 1 - 1
packages/core/src/api/schema/type/order.type.graphql

@@ -64,7 +64,7 @@ type OrderItem implements Node {
     cancelled: Boolean!
     unitPrice: Int!
     unitPriceWithTax: Int!
-    unitPriceIncludesTax: Boolean!
+    unitPriceIncludesTax: Boolean! @deprecated(reason: "`unitPrice` is now always without tax")
     taxRate: Float!
     adjustments: [Adjustment!]!
     fulfillment: Fulfillment

+ 1 - 1
packages/core/src/common/finite-state-machine/validate-transition-definition.spec.ts

@@ -46,7 +46,7 @@ describe('FSM validateTransitionDefinition()', () => {
             },
         };
 
-        const result = validateTransitionDefinition(orderStateTransitions, 'AddingItems');
+        const result = validateTransitionDefinition(orderStateTransitions, 'Created');
 
         expect(result.valid).toBe(true);
     });

+ 22 - 36
packages/core/src/entity/order-item/order-item.entity.ts

@@ -1,6 +1,6 @@
 import { Adjustment, AdjustmentType } from '@vendure/common/lib/generated-types';
 import { DeepPartial, ID } from '@vendure/common/lib/shared-types';
-import { Column, Entity, ManyToOne, OneToOne, RelationId } from 'typeorm';
+import { Column, Entity, ManyToOne, OneToOne } from 'typeorm';
 
 import { Calculated } from '../../common/calculated-decorator';
 import { VendureEntity } from '../base/base.entity';
@@ -28,7 +28,11 @@ export class OrderItem extends VendureEntity {
 
     @Column() readonly unitPrice: number;
 
-    @Column() unitPriceIncludesTax: boolean;
+    /**
+     * @deprecated
+     * TODO: remove once the field has been removed from the GraphQL type
+     */
+    unitPriceIncludesTax = false;
 
     @Column({ type: 'decimal', precision: 5, scale: 2, transformer: new DecimalTransformer() })
     taxRate: number;
@@ -55,11 +59,7 @@ export class OrderItem extends VendureEntity {
 
     @Calculated()
     get unitPriceWithTax(): number {
-        if (this.unitPriceIncludesTax) {
-            return this.unitPrice;
-        } else {
-            return Math.round(this.unitPrice * ((100 + this.taxRate) / 100));
-        }
+        return Math.round(this.unitPrice * ((100 + this.taxRate) / 100));
     }
 
     /**
@@ -70,44 +70,30 @@ export class OrderItem extends VendureEntity {
         if (!this.pendingAdjustments) {
             return [];
         }
-        if (this.unitPriceIncludesTax) {
-            return this.pendingAdjustments;
-        } else {
-            return this.pendingAdjustments.map(a => {
-                if (a.type === AdjustmentType.PROMOTION) {
-                    // Add the tax that would have been payable on the discount so that the numbers add up
-                    // for the end-user.
-                    const adjustmentWithTax = Math.round(a.amount * ((100 + this.taxRate) / 100));
-                    return {
-                        ...a,
-                        amount: adjustmentWithTax,
-                    };
-                }
-                return a;
-            });
-        }
+        return this.pendingAdjustments.map(a => {
+            if (a.type === AdjustmentType.PROMOTION) {
+                // Add the tax that would have been payable on the discount so that the numbers add up
+                // for the end-user.
+                const adjustmentWithTax = Math.round(a.amount * ((100 + this.taxRate) / 100));
+                return {
+                    ...a,
+                    amount: adjustmentWithTax,
+                };
+            }
+            return a;
+        });
     }
 
     /**
      * This is the actual, final price of the OrderItem payable by the customer.
      */
     get unitPriceWithPromotionsAndTax(): number {
-        if (this.unitPriceIncludesTax) {
-            return this.unitPriceWithPromotions;
-        } else {
-            return this.unitPriceWithPromotions + this.unitTax;
-        }
+        return this.unitPriceWithPromotions + this.unitTax;
     }
 
     get unitTax(): number {
-        if (this.unitPriceIncludesTax) {
-            return Math.round(
-                this.unitPriceWithPromotions - this.unitPriceWithPromotions / ((100 + this.taxRate) / 100),
-            );
-        } else {
-            const taxAdjustment = this.adjustments.find(a => a.type === AdjustmentType.TAX);
-            return taxAdjustment ? taxAdjustment.amount : 0;
-        }
+        const taxAdjustment = this.adjustments.find(a => a.type === AdjustmentType.TAX);
+        return taxAdjustment ? taxAdjustment.amount : 0;
     }
 
     get promotionAdjustmentsTotal(): number {

+ 0 - 18
packages/core/src/entity/order-line/order-line.entity.ts

@@ -78,24 +78,6 @@ export class OrderLine extends VendureEntity implements HasCustomFields {
         return (this.items || []).filter(i => !i.cancelled);
     }
 
-    /**
-     * Sets whether the unitPrice of each OrderItem in the line includes tax.
-     */
-    setUnitPriceIncludesTax(includesTax: boolean) {
-        this.activeItems.forEach(item => {
-            item.unitPriceIncludesTax = includesTax;
-        });
-    }
-
-    /**
-     * Sets the tax rate being applied to each Orderitem in this line.
-     */
-    setTaxRate(taxRate: number) {
-        this.activeItems.forEach(item => {
-            item.taxRate = taxRate;
-        });
-    }
-
     /**
      * Clears Adjustments from all OrderItems of the given type. If no type
      * is specified, then all adjustments are removed.

+ 43 - 104
packages/core/src/service/helpers/order-calculator/order-calculator.spec.ts

@@ -85,7 +85,7 @@ describe('OrderCalculator', () => {
     }
 
     describe('taxes', () => {
-        it('single line with taxes not included', async () => {
+        it('single line', async () => {
             const ctx = createRequestContext(false);
             const order = createOrder({
                 lines: [{ unitPrice: 123, taxCategory: taxCategoryStandard, quantity: 1 }],
@@ -96,7 +96,7 @@ describe('OrderCalculator', () => {
             expect(order.subTotalBeforeTax).toBe(123);
         });
 
-        it('single line with taxes not included, multiple items', async () => {
+        it('single line, multiple items', async () => {
             const ctx = createRequestContext(false);
             const order = createOrder({
                 lines: [{ unitPrice: 123, taxCategory: taxCategoryStandard, quantity: 3 }],
@@ -107,19 +107,8 @@ describe('OrderCalculator', () => {
             expect(order.subTotalBeforeTax).toBe(369);
         });
 
-        it('single line with taxes included', async () => {
-            const ctx = createRequestContext(true);
-            const order = createOrder({
-                lines: [{ unitPrice: 123, taxCategory: taxCategoryStandard, quantity: 1 }],
-            });
-            await orderCalculator.applyPriceAdjustments(ctx, order, []);
-
-            expect(order.subTotal).toBe(123);
-            expect(order.subTotalBeforeTax).toBe(102);
-        });
-
         it('resets totals when lines array is empty', async () => {
-            const ctx = createRequestContext(true);
+            const ctx = createRequestContext(false);
             const order = createOrder({
                 lines: [],
                 subTotal: 148,
@@ -183,7 +172,7 @@ describe('OrderCalculator', () => {
             description: [{ languageCode: LanguageCode.en, value: '' }],
             args: { discount: { type: 'int' } },
             execute(ctx, order, args) {
-                return -order.subTotal * (args.discount / 100);
+                return -order.total * (args.discount / 100);
             },
         });
 
@@ -196,13 +185,13 @@ describe('OrderCalculator', () => {
                 promotionActions: [fixedPriceOrderAction],
             });
 
-            const ctx = createRequestContext(true);
+            const ctx = createRequestContext(false);
             const order = createOrder({
                 lines: [{ unitPrice: 123, taxCategory: taxCategoryStandard, quantity: 1 }],
             });
             await orderCalculator.applyPriceAdjustments(ctx, order, [promotion]);
 
-            expect(order.subTotal).toBe(123);
+            expect(order.subTotal).toBe(148);
             expect(order.total).toBe(42);
         });
 
@@ -221,56 +210,29 @@ describe('OrderCalculator', () => {
                 promotionActions: [fixedPriceOrderAction],
             });
 
-            const ctx = createRequestContext(true);
+            const ctx = createRequestContext(false);
             const order = createOrder({
                 lines: [{ unitPrice: 50, taxCategory: taxCategoryStandard, quantity: 1 }],
             });
             await orderCalculator.applyPriceAdjustments(ctx, order, [promotion]);
 
-            expect(order.subTotal).toBe(50);
+            expect(order.subTotal).toBe(60);
             expect(order.adjustments.length).toBe(0);
-            expect(order.total).toBe(50);
+            expect(order.total).toBe(60);
 
             // increase the quantity to 2, which will take the total over the minimum set by the
             // condition.
             order.lines[0].items.push(new OrderItem({ unitPrice: 50 }));
 
-            await orderCalculator.applyPriceAdjustments(ctx, order, [promotion]);
+            await orderCalculator.applyPriceAdjustments(ctx, order, [promotion], order.lines[0]);
 
-            expect(order.subTotal).toBe(100);
+            expect(order.subTotal).toBe(120);
             // Now the fixedPriceOrderAction should be in effect
             expect(order.adjustments.length).toBe(1);
             expect(order.total).toBe(42);
         });
 
-        it('percentage order discount (price includes tax)', async () => {
-            const promotion = new Promotion({
-                id: 1,
-                name: '50% off order',
-                conditions: [{ code: alwaysTrueCondition.code, args: [] }],
-                promotionConditions: [alwaysTrueCondition],
-                actions: [
-                    {
-                        code: percentageOrderAction.code,
-                        args: [{ name: 'discount', value: '50' }],
-                    },
-                ],
-                promotionActions: [percentageOrderAction],
-            });
-
-            const ctx = createRequestContext(true);
-            const order = createOrder({
-                lines: [{ unitPrice: 100, taxCategory: taxCategoryStandard, quantity: 1 }],
-            });
-            await orderCalculator.applyPriceAdjustments(ctx, order, [promotion]);
-
-            expect(order.subTotal).toBe(100);
-            expect(order.adjustments.length).toBe(1);
-            expect(order.adjustments[0].description).toBe('50% off order');
-            expect(order.total).toBe(50);
-        });
-
-        it('percentage order discount (price excludes tax)', async () => {
+        it('percentage order discount', async () => {
             const promotion = new Promotion({
                 id: 1,
                 name: '50% off order',
@@ -287,44 +249,17 @@ describe('OrderCalculator', () => {
 
             const ctx = createRequestContext(false);
             const order = createOrder({
-                lines: [{ unitPrice: 83, taxCategory: taxCategoryStandard, quantity: 1 }],
+                lines: [{ unitPrice: 100, taxCategory: taxCategoryStandard, quantity: 1 }],
             });
             await orderCalculator.applyPriceAdjustments(ctx, order, [promotion]);
 
-            expect(order.subTotal).toBe(100);
+            expect(order.subTotal).toBe(120);
             expect(order.adjustments.length).toBe(1);
             expect(order.adjustments[0].description).toBe('50% off order');
-            expect(order.total).toBe(50);
-        });
-
-        it('percentage items discount (price includes tax)', async () => {
-            const promotion = new Promotion({
-                id: 1,
-                name: '50% off each item',
-                conditions: [{ code: alwaysTrueCondition.code, args: [] }],
-                promotionConditions: [alwaysTrueCondition],
-                actions: [
-                    {
-                        code: percentageItemAction.code,
-                        args: [{ name: 'discount', value: '50' }],
-                    },
-                ],
-                promotionActions: [percentageItemAction],
-            });
-
-            const ctx = createRequestContext(true);
-            const order = createOrder({
-                lines: [{ unitPrice: 100, taxCategory: taxCategoryStandard, quantity: 1 }],
-            });
-            await orderCalculator.applyPriceAdjustments(ctx, order, [promotion]);
-
-            expect(order.subTotal).toBe(50);
-            expect(order.lines[0].adjustments.length).toBe(1);
-            expect(order.lines[0].adjustments[0].description).toBe('50% off each item');
-            expect(order.total).toBe(50);
+            expect(order.total).toBe(60);
         });
 
-        it('percentage items discount (price excludes tax)', async () => {
+        it('percentage items discount', async () => {
             const promotion = new Promotion({
                 id: 1,
                 name: '50% off each item',
@@ -341,12 +276,14 @@ describe('OrderCalculator', () => {
 
             const ctx = createRequestContext(false);
             const order = createOrder({
-                lines: [{ unitPrice: 83, taxCategory: taxCategoryStandard, quantity: 1 }],
+                lines: [{ unitPrice: 8333, taxCategory: taxCategoryStandard, quantity: 1 }],
             });
-            await orderCalculator.applyPriceAdjustments(ctx, order, [promotion]);
+            await orderCalculator.applyPriceAdjustments(ctx, order, [promotion], order.lines[0]);
 
-            expect(order.subTotal).toBe(50);
-            expect(order.total).toBe(50);
+            expect(order.subTotal).toBe(5000);
+            expect(order.lines[0].adjustments.length).toBe(2);
+            expect(order.lines[0].adjustments[0].description).toBe('50% off each item');
+            expect(order.total).toBe(5000);
         });
 
         describe('interaction amongst promotion actions', () => {
@@ -369,7 +306,7 @@ describe('OrderCalculator', () => {
                 },
             });
 
-            const buy3Get10pcOffOrder = new Promotion({
+            const buy3Get50pcOffOrder = new Promotion({
                 id: 1,
                 name: 'Buy 3 Get 50% off order',
                 conditions: [
@@ -407,35 +344,37 @@ describe('OrderCalculator', () => {
                 promotionActions: [percentageOrderAction],
             });
 
-            it('two order-level percentage discounts (tax included in prices)', async () => {
-                const ctx = createRequestContext(true);
+            it('two order-level percentage discounts', async () => {
+                const ctx = createRequestContext(false);
                 const order = createOrder({
                     lines: [{ unitPrice: 50, taxCategory: taxCategoryStandard, quantity: 2 }],
                 });
 
                 // initially the order is $100, so the second promotion applies
                 await orderCalculator.applyPriceAdjustments(ctx, order, [
-                    buy3Get10pcOffOrder,
                     spend100Get10pcOffOrder,
+                    buy3Get50pcOffOrder,
                 ]);
 
-                expect(order.subTotal).toBe(100);
+                expect(order.subTotal).toBe(120);
                 expect(order.adjustments.length).toBe(1);
                 expect(order.adjustments[0].description).toBe(spend100Get10pcOffOrder.name);
-                expect(order.total).toBe(90);
+                expect(order.total).toBe(108);
 
                 // increase the quantity to 3, which will trigger the first promotion and thus
                 // bring the order total below the threshold for the second promotion.
                 order.lines[0].items.push(new OrderItem({ unitPrice: 50 }));
 
-                await orderCalculator.applyPriceAdjustments(ctx, order, [
-                    buy3Get10pcOffOrder,
-                    spend100Get10pcOffOrder,
-                ]);
+                await orderCalculator.applyPriceAdjustments(
+                    ctx,
+                    order,
+                    [spend100Get10pcOffOrder, buy3Get50pcOffOrder],
+                    order.lines[0],
+                );
 
-                expect(order.subTotal).toBe(150);
-                expect(order.adjustments.length).toBe(1);
-                expect(order.total).toBe(75);
+                expect(order.subTotal).toBe(180);
+                expect(order.adjustments.length).toBe(2);
+                expect(order.total).toBe(81);
             });
 
             it('two order-level percentage discounts (tax excluded from prices)', async () => {
@@ -446,7 +385,7 @@ describe('OrderCalculator', () => {
 
                 // initially the order is $100, so the second promotion applies
                 await orderCalculator.applyPriceAdjustments(ctx, order, [
-                    buy3Get10pcOffOrder,
+                    buy3Get50pcOffOrder,
                     spend100Get10pcOffOrder,
                 ]);
 
@@ -462,7 +401,7 @@ describe('OrderCalculator', () => {
                 await orderCalculator.applyPriceAdjustments(
                     ctx,
                     order,
-                    [buy3Get10pcOffOrder, spend100Get10pcOffOrder],
+                    [buy3Get50pcOffOrder, spend100Get10pcOffOrder],
                     order.lines[0],
                 );
 
@@ -502,23 +441,23 @@ describe('OrderCalculator', () => {
             });
 
             it('item-level & order-level percentage discounts', async () => {
-                const ctx = createRequestContext(true);
+                const ctx = createRequestContext(false);
                 const order = createOrder({
                     lines: [{ unitPrice: 155880, taxCategory: taxCategoryStandard, quantity: 1 }],
                 });
                 await orderCalculator.applyPriceAdjustments(ctx, order, [orderPromo, itemPromo]);
 
-                expect(order.total).toBe(155880);
+                expect(order.total).toBe(187056);
 
                 // Apply the item-level discount
                 order.couponCodes.push('ITEM10');
                 await orderCalculator.applyPriceAdjustments(ctx, order, [orderPromo, itemPromo]);
-                expect(order.total).toBe(140292);
+                expect(order.total).toBe(168350);
 
                 // Apply the order-level discount
                 order.couponCodes.push('ORDER10');
                 await orderCalculator.applyPriceAdjustments(ctx, order, [orderPromo, itemPromo]);
-                expect(order.total).toBe(126263);
+                expect(order.total).toBe(151515);
             });
 
             it('item-level & order-level percentage (tax not included)', async () => {

+ 4 - 14
packages/core/src/service/helpers/order-calculator/order-calculator.ts

@@ -108,21 +108,11 @@ export class OrderCalculator {
         line.clearAdjustments(AdjustmentType.TAX);
 
         const applicableTaxRate = getTaxRate(line.taxCategory);
-        const { price, priceIncludesTax, priceWithTax, priceWithoutTax } = this.taxCalculator.calculate(
-            line.unitPrice,
-            line.taxCategory,
-            activeZone,
-            ctx,
-        );
-
         for (const item of line.activeItems) {
-            item.unitPriceIncludesTax = priceIncludesTax;
             item.taxRate = applicableTaxRate.value;
-            if (!priceIncludesTax) {
-                item.pendingAdjustments = item.pendingAdjustments.concat(
-                    applicableTaxRate.apply(item.unitPriceWithPromotions),
-                );
-            }
+            item.pendingAdjustments = item.pendingAdjustments.concat(
+                applicableTaxRate.apply(item.unitPriceWithPromotions),
+            );
         }
     }
 
@@ -291,7 +281,7 @@ export class OrderCalculator {
         let totalTax = 0;
 
         for (const line of order.lines) {
-            totalPrice += line.totalPrice;
+            totalPrice += line.linePriceWithTax;
             totalTax += line.lineTax;
         }
         const totalPriceBeforeTax = totalPrice - totalTax;

+ 1 - 1
packages/core/src/service/helpers/tax-calculator/tax-calculator.ts

@@ -28,7 +28,7 @@ export class TaxCalculator {
     constructor(private configService: ConfigService, private taxRateService: TaxRateService) {}
 
     /**
-     * Given a price and TacxCategory, this method calculates the applicable tax rate and returns the adjusted
+     * Given a price and TaxCategory, this method calculates the applicable tax rate and returns the adjusted
      * price along with other contextual information.
      */
     calculate(

+ 17 - 3
packages/core/src/service/services/order-testing.service.ts

@@ -9,6 +9,7 @@ import {
 
 import { ID } from '../../../../common/lib/shared-types';
 import { RequestContext } from '../../api/common/request-context';
+import { ConfigService } from '../../config/config.service';
 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';
@@ -19,6 +20,8 @@ import { ShippingCalculator } from '../helpers/shipping-calculator/shipping-calc
 import { ShippingConfiguration } from '../helpers/shipping-configuration/shipping-configuration';
 import { TransactionalConnection } from '../transaction/transactional-connection';
 
+import { ProductVariantService } from './product-variant.service';
+
 /**
  * This service is responsible for creating temporary mock Orders against which tests can be run, such as
  * testing a ShippingMethod or Promotion.
@@ -30,6 +33,8 @@ export class OrderTestingService {
         private orderCalculator: OrderCalculator,
         private shippingCalculator: ShippingCalculator,
         private shippingConfiguration: ShippingConfiguration,
+        private configService: ConfigService,
+        private productVariantService: ProductVariantService,
     ) {}
 
     /**
@@ -80,6 +85,7 @@ export class OrderTestingService {
         shippingAddress: CreateAddressInput,
         lines: Array<{ productVariantId: ID; quantity: number }>,
     ): Promise<Order> {
+        const { priceCalculationStrategy } = this.configService.orderOptions;
         const mockOrder = new Order({
             lines: [],
         });
@@ -91,6 +97,7 @@ export class OrderTestingService {
                 line.productVariantId,
                 { relations: ['taxCategory'] },
             );
+            this.productVariantService.applyChannelPriceAndTax(productVariant, ctx);
             const orderLine = new OrderLine({
                 productVariant,
                 items: [],
@@ -98,12 +105,19 @@ export class OrderTestingService {
             });
             mockOrder.lines.push(orderLine);
 
+            const { price, priceIncludesTax } = await priceCalculationStrategy.calculateUnitPrice(
+                ctx,
+                productVariant,
+                orderLine.customFields || {},
+            );
+            const taxRate = productVariant.taxRateApplied;
+            const unitPrice = priceIncludesTax ? taxRate.netPriceOf(price) : price;
+
             for (let i = 0; i < line.quantity; i++) {
                 const orderItem = new OrderItem({
-                    unitPrice: productVariant.price,
+                    unitPrice,
+                    taxRate: taxRate.value,
                     pendingAdjustments: [],
-                    unitPriceIncludesTax: productVariant.priceIncludesTax,
-                    taxRate: productVariant.priceIncludesTax ? productVariant.taxRateApplied.value : 0,
                 });
                 orderLine.items.push(orderItem);
             }

+ 5 - 6
packages/core/src/service/services/order.service.ts

@@ -383,20 +383,19 @@ export class OrderService {
                     orderLine.items = [];
                 }
                 const productVariant = orderLine.productVariant;
-                const calculatedPrice = await priceCalculationStrategy.calculateUnitPrice(
+                const { price, priceIncludesTax } = await priceCalculationStrategy.calculateUnitPrice(
                     ctx,
                     productVariant,
                     orderLine.customFields || {},
                 );
+                const taxRate = productVariant.taxRateApplied;
+                const unitPrice = priceIncludesTax ? taxRate.netPriceOf(price) : price;
                 for (let i = currentQuantity; i < correctedQuantity; i++) {
                     const orderItem = await this.connection.getRepository(ctx, OrderItem).save(
                         new OrderItem({
-                            unitPrice: calculatedPrice.price,
+                            unitPrice,
                             pendingAdjustments: [],
-                            unitPriceIncludesTax: calculatedPrice.priceIncludesTax,
-                            taxRate: productVariant.priceIncludesTax
-                                ? productVariant.taxRateApplied.value
-                                : 0,
+                            taxRate: taxRate.value,
                         }),
                     );
                     orderLine.items.push(orderItem);