diff --git a/CHANGELOG.md b/CHANGELOG.md index f3b2f03f89..a73d6d3d8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## 2.0.9 (2023-09-29) + + +#### Fixes + +* **core** Fix discount calculation error edge-case ([7549aad](https://github.com/vendure-ecommerce/vendure/commit/7549aad)), closes [#2385](https://github.com/vendure-ecommerce/vendure/issues/2385) + ## 2.0.8 (2023-09-27) diff --git a/packages/core/e2e/order-promotion.e2e-spec.ts b/packages/core/e2e/order-promotion.e2e-spec.ts index 6241535566..669e5301fb 100644 --- a/packages/core/e2e/order-promotion.e2e-spec.ts +++ b/packages/core/e2e/order-promotion.e2e-spec.ts @@ -837,6 +837,7 @@ describe('Promotions applied to Orders', () => { describe('discountOnItemWithFacets', () => { const couponCode = '50%_off_sale_items'; let promotion: Codegen.PromotionFragment; + function getItemSale1Line< T extends Array< | CodegenShop.UpdatedOrderFragment['lines'][number] @@ -1943,78 +1944,160 @@ describe('Promotions applied to Orders', () => { }); // https://github.com/vendure-ecommerce/vendure/issues/2385 - it('prevents negative line price', async () => { - await shopClient.asAnonymousUser(); - const item1000 = getVariantBySlug('item-1000')!; + describe('prevents negative line price', () => { + const TAX_INCLUDED_CHANNEL_TOKEN_2 = 'tax_included_channel_2'; const couponCode1 = '100%_off'; const couponCode2 = '100%_off'; - await createPromotion({ - enabled: true, - name: '100% discount ', - couponCode: couponCode1, - conditions: [], - actions: [ - { - code: productsPercentageDiscount.code, - arguments: [ - { name: 'discount', value: '100' }, - { - name: 'productVariantIds', - value: `["${item1000.id}"]`, - }, - ], + let taxIncludedChannel: Codegen.ChannelFragment; + + beforeAll(async () => { + // Create a channel where the prices include tax, so we can ensure + // that PromotionActions are working as expected when taxes are included + const { createChannel } = await adminClient.query< + Codegen.CreateChannelMutation, + Codegen.CreateChannelMutationVariables + >(CREATE_CHANNEL, { + input: { + code: 'tax-included-channel-2', + currencyCode: CurrencyCode.GBP, + pricesIncludeTax: true, + defaultTaxZoneId: 'T_1', + defaultShippingZoneId: 'T_1', + defaultLanguageCode: LanguageCode.en, + token: TAX_INCLUDED_CHANNEL_TOKEN_2, }, - ], - }); - await createPromotion({ - enabled: true, - name: '20% discount ', - couponCode: couponCode2, - conditions: [], - actions: [ - { - code: productsPercentageDiscount.code, - arguments: [ - { name: 'discount', value: '20' }, - { - name: 'productVariantIds', - value: `["${item1000.id}"]`, - }, - ], + }); + taxIncludedChannel = createChannel as Codegen.ChannelFragment; + await adminClient.query< + Codegen.AssignProductsToChannelMutation, + Codegen.AssignProductsToChannelMutationVariables + >(ASSIGN_PRODUCT_TO_CHANNEL, { + input: { + channelId: taxIncludedChannel.id, + priceFactor: 1, + productIds: products.map(p => p.id), }, - ], + }); + const item1000 = getVariantBySlug('item-1000')!; + const promo100 = await createPromotion({ + enabled: true, + name: '100% discount ', + couponCode: couponCode1, + conditions: [], + actions: [ + { + code: productsPercentageDiscount.code, + arguments: [ + { name: 'discount', value: '100' }, + { + name: 'productVariantIds', + value: `["${item1000.id}"]`, + }, + ], + }, + ], + }); + const promo20 = await createPromotion({ + enabled: true, + name: '20% discount ', + couponCode: couponCode2, + conditions: [], + actions: [ + { + code: productsPercentageDiscount.code, + arguments: [ + { name: 'discount', value: '20' }, + { + name: 'productVariantIds', + value: `["${item1000.id}"]`, + }, + ], + }, + ], + }); + await adminClient.query< + Codegen.AssignPromotionToChannelMutation, + Codegen.AssignPromotionToChannelMutationVariables + >(ASSIGN_PROMOTIONS_TO_CHANNEL, { + input: { + promotionIds: [promo100.id, promo20.id], + channelId: taxIncludedChannel.id, + }, + }); }); - await shopClient.query< - CodegenShop.ApplyCouponCodeMutation, - CodegenShop.ApplyCouponCodeMutationVariables - >(APPLY_COUPON_CODE, { couponCode: couponCode1 }); + it('prices exclude tax', async () => { + await shopClient.asAnonymousUser(); + const item1000 = getVariantBySlug('item-1000')!; - await shopClient.query< - CodegenShop.AddItemToOrderMutation, - CodegenShop.AddItemToOrderMutationVariables - >(ADD_ITEM_TO_ORDER, { - productVariantId: item1000.id, - quantity: 1, + await shopClient.query< + CodegenShop.ApplyCouponCodeMutation, + CodegenShop.ApplyCouponCodeMutationVariables + >(APPLY_COUPON_CODE, { couponCode: couponCode1 }); + + await shopClient.query< + CodegenShop.AddItemToOrderMutation, + CodegenShop.AddItemToOrderMutationVariables + >(ADD_ITEM_TO_ORDER, { + productVariantId: item1000.id, + quantity: 1, + }); + + const { activeOrder: check1 } = await shopClient.query( + GET_ACTIVE_ORDER, + ); + + expect(check1!.lines[0].discountedUnitPriceWithTax).toBe(0); + expect(check1!.totalWithTax).toBe(0); + + await shopClient.query< + CodegenShop.ApplyCouponCodeMutation, + CodegenShop.ApplyCouponCodeMutationVariables + >(APPLY_COUPON_CODE, { couponCode: couponCode2 }); + + const { activeOrder: check2 } = await shopClient.query( + GET_ACTIVE_ORDER, + ); + expect(check2!.lines[0].discountedUnitPriceWithTax).toBe(0); + expect(check2!.totalWithTax).toBe(0); }); - const { activeOrder: check1 } = await shopClient.query( - GET_ACTIVE_ORDER, - ); + it('prices include tax', async () => { + shopClient.setChannelToken(TAX_INCLUDED_CHANNEL_TOKEN_2); + await shopClient.asAnonymousUser(); + const item1000 = getVariantBySlug('item-1000')!; - expect(check1!.lines[0].discountedUnitPriceWithTax).toBe(0); - expect(check1!.totalWithTax).toBe(0); + await shopClient.query< + CodegenShop.ApplyCouponCodeMutation, + CodegenShop.ApplyCouponCodeMutationVariables + >(APPLY_COUPON_CODE, { couponCode: couponCode1 }); - await shopClient.query< - CodegenShop.ApplyCouponCodeMutation, - CodegenShop.ApplyCouponCodeMutationVariables - >(APPLY_COUPON_CODE, { couponCode: couponCode2 }); + await shopClient.query< + CodegenShop.AddItemToOrderMutation, + CodegenShop.AddItemToOrderMutationVariables + >(ADD_ITEM_TO_ORDER, { + productVariantId: item1000.id, + quantity: 1, + }); - const { activeOrder: check2 } = await shopClient.query( - GET_ACTIVE_ORDER, - ); - expect(check2!.lines[0].discountedUnitPriceWithTax).toBe(0); - expect(check2!.totalWithTax).toBe(0); + const { activeOrder: check1 } = await shopClient.query( + GET_ACTIVE_ORDER, + ); + + expect(check1!.lines[0].discountedUnitPriceWithTax).toBe(0); + expect(check1!.totalWithTax).toBe(0); + + await shopClient.query< + CodegenShop.ApplyCouponCodeMutation, + CodegenShop.ApplyCouponCodeMutationVariables + >(APPLY_COUPON_CODE, { couponCode: couponCode2 }); + + const { activeOrder: check2 } = await shopClient.query( + GET_ACTIVE_ORDER, + ); + expect(check2!.lines[0].discountedUnitPriceWithTax).toBe(0); + expect(check2!.totalWithTax).toBe(0); + }); }); async function getProducts() { diff --git a/packages/core/src/config/promotion/actions/product-percentage-discount-action.ts b/packages/core/src/config/promotion/actions/product-percentage-discount-action.ts index 10a6b92e64..2f1bb55ee0 100644 --- a/packages/core/src/config/promotion/actions/product-percentage-discount-action.ts +++ b/packages/core/src/config/promotion/actions/product-percentage-discount-action.ts @@ -23,7 +23,6 @@ export const productsPercentageDiscount = new PromotionItemAction({ label: [{ languageCode: LanguageCode.en, value: 'Product variants' }], }, }, - execute(ctx, orderLine, args) { if (lineContainsIds(args.productVariantIds, orderLine)) { const unitPrice = ctx.channel.pricesIncludeTax ? orderLine.unitPriceWithTax : orderLine.unitPrice; 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 198d75df8e..990d01e03b 100644 --- a/packages/core/src/entity/order-line/order-line.entity.ts +++ b/packages/core/src/entity/order-line/order-line.entity.ts @@ -332,7 +332,8 @@ export class OrderLine extends VendureEntity implements HasCustomFields { addAdjustment(adjustment: Adjustment) { // We should not allow adding adjustments which would // result in a negative unit price - const maxDiscount = this.proratedLinePrice * -1; + const maxDiscount = + (this.listPriceIncludesTax ? this.proratedLinePriceWithTax : this.proratedLinePrice) * -1; const limitedAdjustment: Adjustment = { ...adjustment, amount: Math.max(maxDiscount, adjustment.amount),