From 0ff82885b6b4062e2ec636146f955f8eb04cd236 Mon Sep 17 00:00:00 2001 From: Tiis Date: Thu, 25 Jul 2024 22:07:18 +0900 Subject: [PATCH] feat(core): Create PromotionLineAction (#2971) Closes #2956 --- .../core/e2e/fixtures/test-money-strategy.ts | 7 ++ packages/core/e2e/order-promotion.e2e-spec.ts | 107 +++++++++++++----- .../order-line-fixed-discount-action.ts | 19 ++++ packages/core/src/config/promotion/index.ts | 2 + .../src/config/promotion/promotion-action.ts | 91 ++++++++++++++- .../src/entity/promotion/promotion.entity.ts | 27 ++++- .../order-calculator/order-calculator.ts | 3 +- 7 files changed, 222 insertions(+), 34 deletions(-) create mode 100644 packages/core/e2e/fixtures/test-money-strategy.ts create mode 100644 packages/core/src/config/promotion/actions/order-line-fixed-discount-action.ts 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..c17019ef44 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(), + }, }), ); @@ -834,6 +839,58 @@ describe('Promotions applied to Orders', () => { }); }); + describe('orderLineFixedDiscount', () => { + const couponCode = '1000_off_order_line'; + let promotion: Codegen.PromotionFragment; + + beforeAll(async () => { + promotion = await createPromotion({ + enabled: true, + name: '$1000 discount on order line', + couponCode, + conditions: [], + actions: [ + { + code: orderLineFixedDiscount.code, + arguments: [{ name: 'discount', value: '1000' }], + }, + ], + }); + }); + + afterAll(async () => { + await deletePromotion(promotion.id); + }); + + it('prices exclude tax', async () => { + 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.discounts.length).toBe(0); + expect(addItemToOrder.lines[0].discounts.length).toBe(0); + expect(addItemToOrder.total).toBe(3000); + expect(addItemToOrder.totalWithTax).toBe(3600); + + const { applyCouponCode } = await shopClient.query< + CodegenShop.ApplyCouponCodeMutation, + CodegenShop.ApplyCouponCodeMutationVariables + >(APPLY_COUPON_CODE, { + couponCode, + }); + orderResultGuard.assertSuccess(applyCouponCode); + + expect(applyCouponCode.total).toBe(2000); + expect(applyCouponCode.totalWithTax).toBe(2400); + expect(applyCouponCode.lines[0].discounts.length).toBe(1); + }); + }); + describe('discountOnItemWithFacets', () => { const couponCode = '50%_off_sale_items'; let promotion: Codegen.PromotionFragment; @@ -925,9 +982,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 +1042,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 +1589,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 +1681,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 +1936,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 +1951,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 +2094,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 +2105,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 +2129,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,9 +2140,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); }); 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..93d93f7ffc --- /dev/null +++ b/packages/core/src/config/promotion/actions/order-line-fixed-discount-action.ts @@ -0,0 +1,19 @@ +import { LanguageCode } from '@vendure/common/lib/generated-types'; + +import { PromotionLineAction } from '../promotion-action'; + +export const orderLineFixedDiscount = new PromotionLineAction({ + code: 'order_line_fixed_discount', + args: { + discount: { + type: 'int', + ui: { + component: 'currency-form-input', + }, + }, + }, + execute(ctx, orderLine, args) { + return -args.discount; + }, + 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..a27ef4e820 100644 --- a/packages/core/src/config/promotion/promotion-action.ts +++ b/packages/core/src/config/promotion/promotion-action.ts @@ -64,7 +64,7 @@ export type ConditionState< /** * @description * The function which is used by a PromotionItemAction to calculate the - * discount on the OrderLine. + * discount on the OrderLine for each item. * * @docsCategory promotions * @docsPage promotion-action @@ -77,6 +77,22 @@ export type ExecutePromotionItemActionFn number | Promise; +/** + * @description + * The function which is used by a PromotionLineAction to calculate the + * discount on the OrderLine. + * + * @docsCategory promotions + * @docsPage promotion-action + */ +export type ExecutePromotionLineActionFn>> = ( + ctx: RequestContext, + orderLine: OrderLine, + args: ConfigArgValues, + state: ConditionState, + promotion: Promotion, +) => number | Promise; + /** * @description * The function which is used by a PromotionOrderAction to calculate the @@ -201,6 +217,24 @@ export interface PromotionItemActionConfig; } +/** + * @description + * Configuration for a {@link PromotionLineAction} + * + * @docsCategory promotions + * @docsPage promotion-action + */ +export interface PromotionLineActionConfig + extends PromotionActionConfig { + /** + * @description + * The function which contains the promotion calculation logic. + * Should resolve to a number which represents the amount by which to discount + * the OrderLine, i.e. the number should be negative. + */ + execute: ExecutePromotionLineActionFn; +} + /** * @description * @@ -351,6 +385,61 @@ export class PromotionItemAction< } } +/** + * @description + * Represents a PromotionAction which applies to individual {@link OrderLine}s. + * The difference from PromotionItemAction is that it applies regardless of the Quantity of the OrderLine. + * + * @example + * ```ts + * // Applies a percentage discount to each OrderLine + * const linePercentageDiscount = new PromotionLineAction({ + * code: 'line_percentage_discount', + * args: { discount: 'percentage' }, + * execute(ctx, orderLine, args) { + * return -orderLine.linePrice * (args.discount / 100); + * }, + * description: 'Discount every line by { discount }%', + * }); + * ``` + * + * @docsCategory promotions + * @docsPage promotion-action + */ +export class PromotionLineAction< + T extends ConfigArgs = ConfigArgs, + U extends Array> = [], +> extends PromotionAction { + private readonly executeFn: ExecutePromotionLineActionFn; + constructor(config: PromotionLineActionConfig) { + super(config); + this.executeFn = config.execute; + } + + /** @internal */ + execute( + ctx: RequestContext, + orderLine: OrderLine, + args: ConfigArg[], + state: PromotionState, + promotion: Promotion, + ) { + const actionState = this.conditions + ? pick( + state, + this.conditions.map(c => c.code), + ) + : {}; + return this.executeFn( + ctx, + orderLine, + this.argsArrayToHash(args), + actionState as ConditionState, + promotion, + ); + } +} + /** * @description * Represents a PromotionAction which applies to the {@link Order} as a whole. diff --git a/packages/core/src/entity/promotion/promotion.entity.ts b/packages/core/src/entity/promotion/promotion.entity.ts index d7a44f2a8d..06787f171b 100644 --- a/packages/core/src/entity/promotion/promotion.entity.ts +++ b/packages/core/src/entity/promotion/promotion.entity.ts @@ -12,6 +12,7 @@ import { HasCustomFields } from '../../config/custom-field/custom-field-types'; import { PromotionAction, PromotionItemAction, + PromotionLineAction, PromotionOrderAction, PromotionShippingAction, } from '../../config/promotion/promotion-action'; @@ -28,6 +29,10 @@ export interface ApplyOrderItemActionArgs { orderLine: OrderLine; } +export interface ApplyOrderLineActionArgs { + orderLine: OrderLine; +} + export interface ApplyOrderActionArgs { order: Order; } @@ -49,7 +54,7 @@ export type PromotionTestResult = boolean | PromotionState; * will be applied to an Order. * * Each assigned {@link PromotionCondition} is checked against the Order, and if they all return `true`, - * then each assign {@link PromotionItemAction} / {@link PromotionOrderAction} is applied to the Order. + * then each assign {@link PromotionItemAction} / {@link PromotionLineAction} / {@link PromotionOrderAction} / {@link PromotionShippingAction} is applied to the Order. * * @docsCategory entities */ @@ -61,7 +66,11 @@ export class Promotion type = AdjustmentType.PROMOTION; private readonly allConditions: { [code: string]: PromotionCondition } = {}; private readonly allActions: { - [code: string]: PromotionItemAction | PromotionOrderAction | PromotionShippingAction; + [code: string]: + | PromotionItemAction + | PromotionLineAction + | PromotionOrderAction + | PromotionShippingAction; } = {}; constructor( @@ -149,6 +158,14 @@ export class Promotion const promotionAction = this.allActions[action.code]; if (promotionAction instanceof PromotionItemAction) { if (this.isOrderItemArg(args)) { + const { orderLine } = args; + amount += roundMoney( + await promotionAction.execute(ctx, orderLine, action.args, state, this), + orderLine.quantity, + ); + } + } else if (promotionAction instanceof PromotionLineAction) { + if (this.isOrderLineArg(args)) { const { orderLine } = args; amount += roundMoney( await promotionAction.execute(ctx, orderLine, action.args, state, this), @@ -237,6 +254,12 @@ export class Promotion return !this.isOrderItemArg(value) && !this.isShippingArg(value); } + private isOrderLineArg( + value: ApplyOrderLineActionArgs | ApplyOrderActionArgs | ApplyShippingActionArgs, + ): value is ApplyOrderLineActionArgs { + return value.hasOwnProperty('orderLine'); + } + private isOrderItemArg( value: ApplyOrderItemActionArgs | ApplyOrderActionArgs | ApplyShippingActionArgs, ): value is ApplyOrderItemActionArgs { 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..26f620a097 100644 --- a/packages/core/src/service/helpers/order-calculator/order-calculator.ts +++ b/packages/core/src/service/helpers/order-calculator/order-calculator.ts @@ -176,6 +176,8 @@ export class OrderCalculator { * Applies promotions to OrderItems. This is a quite complex function, due to the inherent complexity * of applying the promotions, and also due to added complexity in the name of performance * optimization. Therefore, it is heavily annotated so that the purpose of each step is clear. + * Additionally, this is used in both promotionItemAction and promotionLineAction, + * as it is difficult to separate action types at this stage. */ private async applyOrderItemPromotions( ctx: RequestContext, @@ -199,7 +201,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; }