diff --git a/packages/core/e2e/shipping-method.e2e-spec.ts b/packages/core/e2e/shipping-method.e2e-spec.ts index b45953ddd9..0ce49c1611 100644 --- a/packages/core/e2e/shipping-method.e2e-spec.ts +++ b/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, diff --git a/packages/core/src/api/schema/type/order.type.graphql b/packages/core/src/api/schema/type/order.type.graphql index caf7a6d43a..a371d1595e 100644 --- a/packages/core/src/api/schema/type/order.type.graphql +++ b/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 diff --git a/packages/core/src/common/finite-state-machine/validate-transition-definition.spec.ts b/packages/core/src/common/finite-state-machine/validate-transition-definition.spec.ts index 4cb0f72782..6e9996466f 100644 --- a/packages/core/src/common/finite-state-machine/validate-transition-definition.spec.ts +++ b/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); }); diff --git a/packages/core/src/entity/order-item/order-item.entity.ts b/packages/core/src/entity/order-item/order-item.entity.ts index c1c422d971..7c710be71c 100644 --- a/packages/core/src/entity/order-item/order-item.entity.ts +++ b/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 { diff --git a/packages/core/src/entity/order-line/order-line.entity.ts b/packages/core/src/entity/order-line/order-line.entity.ts index ab60e63dc3..96651d6355 100644 --- a/packages/core/src/entity/order-line/order-line.entity.ts +++ b/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. diff --git a/packages/core/src/service/helpers/order-calculator/order-calculator.spec.ts b/packages/core/src/service/helpers/order-calculator/order-calculator.spec.ts index f2547e8c28..d8ee99feaf 100644 --- a/packages/core/src/service/helpers/order-calculator/order-calculator.spec.ts +++ b/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 () => { diff --git a/packages/core/src/service/helpers/order-calculator/order-calculator.ts b/packages/core/src/service/helpers/order-calculator/order-calculator.ts index d98d7d1970..ef7e7da70c 100644 --- a/packages/core/src/service/helpers/order-calculator/order-calculator.ts +++ b/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; diff --git a/packages/core/src/service/helpers/tax-calculator/tax-calculator.ts b/packages/core/src/service/helpers/tax-calculator/tax-calculator.ts index 39c2aeabcc..b3bd077f25 100644 --- a/packages/core/src/service/helpers/tax-calculator/tax-calculator.ts +++ b/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( diff --git a/packages/core/src/service/services/order-testing.service.ts b/packages/core/src/service/services/order-testing.service.ts index 82b930278c..f409a81a3b 100644 --- a/packages/core/src/service/services/order-testing.service.ts +++ b/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 { + 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); } diff --git a/packages/core/src/service/services/order.service.ts b/packages/core/src/service/services/order.service.ts index c773dd8c2e..1e46071ffb 100644 --- a/packages/core/src/service/services/order.service.ts +++ b/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);