diff --git a/packages/core/e2e/fixtures/test-money-strategy.ts b/packages/core/e2e/fixtures/test-money-strategy.ts new file mode 100644 index 0000000000..e4bef62600 --- /dev/null +++ b/packages/core/e2e/fixtures/test-money-strategy.ts @@ -0,0 +1,7 @@ +import { DefaultMoneyStrategy } from '@vendure/core'; + +export class TestMoneyStrategy extends DefaultMoneyStrategy { + round(value: number, quantity = 1): number { + return Math.round(value * quantity); + } +} diff --git a/packages/core/e2e/order-promotion.e2e-spec.ts b/packages/core/e2e/order-promotion.e2e-spec.ts index 359aab889b..520da49dd1 100644 --- a/packages/core/e2e/order-promotion.e2e-spec.ts +++ b/packages/core/e2e/order-promotion.e2e-spec.ts @@ -28,7 +28,9 @@ import { initialData } from '../../../e2e-common/e2e-initial-data'; import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config'; import { freeShipping } from '../src/config/promotion/actions/free-shipping-action'; import { orderFixedDiscount } from '../src/config/promotion/actions/order-fixed-discount-action'; +import { orderLineFixedDiscount } from '../src/config/promotion/actions/order-line-fixed-discount-action'; +import { TestMoneyStrategy } from './fixtures/test-money-strategy'; import { testSuccessfulPaymentMethod } from './fixtures/test-payment-methods'; import { CurrencyCode, HistoryEntryType, LanguageCode } from './graphql/generated-e2e-admin-types'; import * as Codegen from './graphql/generated-e2e-admin-types'; @@ -66,6 +68,9 @@ describe('Promotions applied to Orders', () => { paymentOptions: { paymentMethodHandlers: [testSuccessfulPaymentMethod], }, + entityOptions: { + moneyStrategy: new TestMoneyStrategy(), + }, }), ); @@ -925,9 +930,8 @@ describe('Promotions applied to Orders', () => { expect(removeCouponCode!.total).toBe(2200); expect(removeCouponCode!.totalWithTax).toBe(2640); - const { activeOrder } = await shopClient.query( - GET_ACTIVE_ORDER, - ); + const { activeOrder } = + await shopClient.query(GET_ACTIVE_ORDER); expect(getItemSale1Line(activeOrder!.lines).discounts.length).toBe(0); expect(activeOrder!.total).toBe(2200); expect(activeOrder!.totalWithTax).toBe(2640); @@ -986,9 +990,8 @@ describe('Promotions applied to Orders', () => { expect(removeCouponCode!.total).toBe(2200); expect(removeCouponCode!.totalWithTax).toBe(2640); - const { activeOrder } = await shopClient.query( - GET_ACTIVE_ORDER, - ); + const { activeOrder } = + await shopClient.query(GET_ACTIVE_ORDER); expect(getItemSale1Line(activeOrder!.lines).discounts.length).toBe(0); expect(activeOrder!.total).toBe(2200); expect(activeOrder!.totalWithTax).toBe(2640); @@ -1534,9 +1537,8 @@ describe('Promotions applied to Orders', () => { await addGuestCustomerToOrder(); - const { activeOrder } = await shopClient.query( - GET_ACTIVE_ORDER, - ); + const { activeOrder } = + await shopClient.query(GET_ACTIVE_ORDER); expect(activeOrder!.couponCodes).toEqual([]); expect(activeOrder!.totalWithTax).toBe(6000); }); @@ -1627,9 +1629,8 @@ describe('Promotions applied to Orders', () => { await logInAsRegisteredCustomer(); - const { activeOrder } = await shopClient.query( - GET_ACTIVE_ORDER, - ); + const { activeOrder } = + await shopClient.query(GET_ACTIVE_ORDER); expect(activeOrder!.totalWithTax).toBe(6000); expect(activeOrder!.couponCodes).toEqual([]); }); @@ -1883,9 +1884,8 @@ describe('Promotions applied to Orders', () => { expect(addItemToOrder.discounts.length).toBe(1); expect(addItemToOrder.discounts[0].description).toBe('Test Promo'); - const { activeOrder: check1 } = await shopClient.query( - GET_ACTIVE_ORDER, - ); + const { activeOrder: check1 } = + await shopClient.query(GET_ACTIVE_ORDER); expect(check1!.discounts.length).toBe(1); expect(check1!.discounts[0].description).toBe('Test Promo'); @@ -1899,9 +1899,8 @@ describe('Promotions applied to Orders', () => { orderResultGuard.assertSuccess(removeOrderLine); expect(removeOrderLine.discounts.length).toBe(0); - const { activeOrder: check2 } = await shopClient.query( - GET_ACTIVE_ORDER, - ); + const { activeOrder: check2 } = + await shopClient.query(GET_ACTIVE_ORDER); expect(check2!.discounts.length).toBe(0); }); @@ -2043,9 +2042,8 @@ describe('Promotions applied to Orders', () => { quantity: 1, }); - const { activeOrder: check1 } = await shopClient.query( - GET_ACTIVE_ORDER, - ); + const { activeOrder: check1 } = + await shopClient.query(GET_ACTIVE_ORDER); expect(check1!.lines[0].discountedUnitPriceWithTax).toBe(0); expect(check1!.totalWithTax).toBe(0); @@ -2055,9 +2053,8 @@ describe('Promotions applied to Orders', () => { CodegenShop.ApplyCouponCodeMutationVariables >(APPLY_COUPON_CODE, { couponCode: couponCode2 }); - const { activeOrder: check2 } = await shopClient.query( - GET_ACTIVE_ORDER, - ); + const { activeOrder: check2 } = + await shopClient.query(GET_ACTIVE_ORDER); expect(check2!.lines[0].discountedUnitPriceWithTax).toBe(0); expect(check2!.totalWithTax).toBe(0); }); @@ -2080,9 +2077,8 @@ describe('Promotions applied to Orders', () => { quantity: 1, }); - const { activeOrder: check1 } = await shopClient.query( - GET_ACTIVE_ORDER, - ); + const { activeOrder: check1 } = + await shopClient.query(GET_ACTIVE_ORDER); expect(check1!.lines[0].discountedUnitPriceWithTax).toBe(0); expect(check1!.totalWithTax).toBe(0); @@ -2092,14 +2088,115 @@ describe('Promotions applied to Orders', () => { CodegenShop.ApplyCouponCodeMutationVariables >(APPLY_COUPON_CODE, { couponCode: couponCode2 }); - const { activeOrder: check2 } = await shopClient.query( - GET_ACTIVE_ORDER, - ); + const { activeOrder: check2 } = + await shopClient.query(GET_ACTIVE_ORDER); expect(check2!.lines[0].discountedUnitPriceWithTax).toBe(0); expect(check2!.totalWithTax).toBe(0); }); }); + // https://github.com/vendure-ecommerce/vendure/issues/2956 + describe('Order promotion with ExecutePromotionActionResult', () => { + const lineDiscountCouponCode = 'LINE_DISCOUNT'; + const unitDiscountCouponCode = 'UNIT_DISCOUNT'; + let lineDiscountPromotion: Codegen.PromotionFragment; + let unitDiscountPromotion: Codegen.PromotionFragment; + + beforeAll(async () => { + lineDiscountPromotion = await createPromotion({ + enabled: true, + name: 'Line discount promotion', + couponCode: lineDiscountCouponCode, + conditions: [], + actions: [ + { + code: orderLineFixedDiscount.code, + arguments: [ + { name: 'discount', value: '800' }, + { name: 'discountMode', value: 'line' }, + ], + }, + ], + }); + unitDiscountPromotion = await createPromotion({ + enabled: true, + name: 'Unit discount promotion', + couponCode: unitDiscountCouponCode, + conditions: [], + actions: [ + { + code: orderLineFixedDiscount.code, + arguments: [ + { name: 'discount', value: '300' }, + { name: 'discountMode', value: 'unit' }, + ], + }, + ], + }); + }); + + afterAll(async () => { + await deletePromotion(lineDiscountPromotion.id); + await deletePromotion(unitDiscountPromotion.id); + }); + + it('should apply line discount promotion correctly', async () => { + shopClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN); + await shopClient.asAnonymousUser(); + const { addItemToOrder } = await shopClient.query< + CodegenShop.AddItemToOrderMutation, + CodegenShop.AddItemToOrderMutationVariables + >(ADD_ITEM_TO_ORDER, { + productVariantId: getVariantBySlug('item-1000').id, + quantity: 3, + }); + orderResultGuard.assertSuccess(addItemToOrder); + expect(addItemToOrder.total).toBe(3000); + expect(addItemToOrder.totalWithTax).toBe(3600); + expect(addItemToOrder.discounts.length).toBe(0); + + const { applyCouponCode } = await shopClient.query< + CodegenShop.ApplyCouponCodeMutation, + CodegenShop.ApplyCouponCodeMutationVariables + >(APPLY_COUPON_CODE, { + couponCode: lineDiscountCouponCode, + }); + orderResultGuard.assertSuccess(applyCouponCode); + expect(applyCouponCode.discounts.length).toBe(1); + expect(applyCouponCode.discounts[0].description).toBe('Line discount promotion'); + expect(applyCouponCode.total).toBe(2200); + expect(applyCouponCode.totalWithTax).toBe(2640); + }); + + it('should apply unit discount promotion correctly', async () => { + shopClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN); + await shopClient.asAnonymousUser(); + const { addItemToOrder } = await shopClient.query< + CodegenShop.AddItemToOrderMutation, + CodegenShop.AddItemToOrderMutationVariables + >(ADD_ITEM_TO_ORDER, { + productVariantId: getVariantBySlug('item-1000').id, + quantity: 3, + }); + orderResultGuard.assertSuccess(addItemToOrder); + expect(addItemToOrder.total).toBe(3000); + expect(addItemToOrder.totalWithTax).toBe(3600); + expect(addItemToOrder.discounts.length).toBe(0); + + const { applyCouponCode } = await shopClient.query< + CodegenShop.ApplyCouponCodeMutation, + CodegenShop.ApplyCouponCodeMutationVariables + >(APPLY_COUPON_CODE, { + couponCode: unitDiscountCouponCode, + }); + orderResultGuard.assertSuccess(applyCouponCode); + expect(applyCouponCode.discounts.length).toBe(1); + expect(applyCouponCode.discounts[0].description).toBe('Unit discount promotion'); + expect(applyCouponCode.total).toBe(2100); + expect(applyCouponCode.totalWithTax).toBe(2520); + }); + }); + async function getProducts() { const result = await adminClient.query( GET_PRODUCTS_WITH_VARIANT_PRICES, diff --git a/packages/core/src/config/promotion/actions/order-line-fixed-discount-action.ts b/packages/core/src/config/promotion/actions/order-line-fixed-discount-action.ts new file mode 100644 index 0000000000..b3bb5c74ae --- /dev/null +++ b/packages/core/src/config/promotion/actions/order-line-fixed-discount-action.ts @@ -0,0 +1,32 @@ +import { LanguageCode } from '@vendure/common/lib/generated-types'; + +import { PromotionItemAction } from '../promotion-action'; + +export const orderLineFixedDiscount = new PromotionItemAction({ + code: 'order_line_fixed_discount', + args: { + discount: { + type: 'int', + ui: { + component: 'currency-form-input', + }, + }, + discountMode: { + type: 'string', + ui: { + component: 'select-form-input', + options: [ + { label: 'Unit', value: 'unit' }, + { label: 'Line', value: 'line' }, + ], + }, + }, + }, + execute(ctx, orderLine, args) { + return { + amount: -args.discount, + discountMode: (args.discountMode as 'unit' | 'line') || 'line', + }; + }, + description: [{ languageCode: LanguageCode.en, value: 'Discount orderLine by fixed amount' }], +}); diff --git a/packages/core/src/config/promotion/index.ts b/packages/core/src/config/promotion/index.ts index 77c8d01207..2bf015f4a7 100644 --- a/packages/core/src/config/promotion/index.ts +++ b/packages/core/src/config/promotion/index.ts @@ -2,6 +2,7 @@ import { buyXGetYFreeAction } from './actions/buy-x-get-y-free-action'; import { discountOnItemWithFacets } from './actions/facet-values-percentage-discount-action'; import { freeShipping } from './actions/free-shipping-action'; import { orderFixedDiscount } from './actions/order-fixed-discount-action'; +import { orderLineFixedDiscount } from './actions/order-line-fixed-discount-action'; import { orderPercentageDiscount } from './actions/order-percentage-discount-action'; import { productsPercentageDiscount } from './actions/product-percentage-discount-action'; import { buyXGetYFreeCondition } from './conditions/buy-x-get-y-free-condition'; @@ -27,6 +28,7 @@ export * from './utils/facet-value-checker'; export const defaultPromotionActions = [ orderFixedDiscount, + orderLineFixedDiscount, orderPercentageDiscount, discountOnItemWithFacets, productsPercentageDiscount, diff --git a/packages/core/src/config/promotion/promotion-action.ts b/packages/core/src/config/promotion/promotion-action.ts index 5bc31358a4..f1088922aa 100644 --- a/packages/core/src/config/promotion/promotion-action.ts +++ b/packages/core/src/config/promotion/promotion-action.ts @@ -61,6 +61,18 @@ export type ConditionState< T extends [string, any] = TupleToUnion>>, > = { [key in T[0]]: Extract[1] }; +/** + * @description + * The result of a PromotionItemAction's `execute` function. + * + * @docsCategory promotions + * @docsPage promotion-action + */ +type ExecutePromotionActionResult = { + amount: number; + discountMode: 'line' | 'unit'; +}; + /** * @description * The function which is used by a PromotionItemAction to calculate the @@ -75,7 +87,7 @@ export type ExecutePromotionItemActionFn, state: ConditionState, promotion: Promotion, -) => number | Promise; +) => number | Promise | ExecutePromotionActionResult | Promise; /** * @description @@ -273,7 +285,9 @@ export abstract class PromotionAction< } /** @internal */ - abstract execute(...arg: any[]): number | Promise; + abstract execute( + ...arg: any[] + ): number | Promise | ExecutePromotionActionResult | Promise; /** @internal */ onActivate( diff --git a/packages/core/src/entity/promotion/promotion.entity.ts b/packages/core/src/entity/promotion/promotion.entity.ts index d7a44f2a8d..85ea6c28c8 100644 --- a/packages/core/src/entity/promotion/promotion.entity.ts +++ b/packages/core/src/entity/promotion/promotion.entity.ts @@ -150,9 +150,15 @@ export class Promotion if (promotionAction instanceof PromotionItemAction) { if (this.isOrderItemArg(args)) { const { orderLine } = args; - amount += roundMoney( - await promotionAction.execute(ctx, orderLine, action.args, state, this), - ); + const discount = await promotionAction.execute(ctx, orderLine, action.args, state, this); + if (typeof discount === 'number') { + amount += roundMoney(discount * orderLine.quantity); + } else { + amount += + discount.discountMode === 'line' + ? roundMoney(discount.amount) + : roundMoney(discount.amount * orderLine.quantity); + } } } else if (promotionAction instanceof PromotionOrderAction) { if (this.isOrderArg(args)) { 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 b8ee2609c7..05eef9ff54 100644 --- a/packages/core/src/service/helpers/order-calculator/order-calculator.ts +++ b/packages/core/src/service/helpers/order-calculator/order-calculator.ts @@ -199,7 +199,6 @@ export class OrderCalculator { // for (const item of line.items) { const adjustment = await promotion.apply(ctx, { orderLine: line }, state); if (adjustment) { - adjustment.amount = adjustment.amount * line.quantity; line.addAdjustment(adjustment); priceAdjusted = true; }