Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): Create PromotionLineAction #2971

Merged
merged 3 commits into from
Jul 25, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/core/e2e/fixtures/test-money-strategy.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
107 changes: 77 additions & 30 deletions packages/core/e2e/order-promotion.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -66,6 +68,9 @@ describe('Promotions applied to Orders', () => {
paymentOptions: {
paymentMethodHandlers: [testSuccessfulPaymentMethod],
},
entityOptions: {
moneyStrategy: new TestMoneyStrategy(),
},
}),
);

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -925,9 +982,8 @@ describe('Promotions applied to Orders', () => {
expect(removeCouponCode!.total).toBe(2200);
expect(removeCouponCode!.totalWithTax).toBe(2640);

const { activeOrder } = await shopClient.query<CodegenShop.GetActiveOrderQuery>(
GET_ACTIVE_ORDER,
);
const { activeOrder } =
await shopClient.query<CodegenShop.GetActiveOrderQuery>(GET_ACTIVE_ORDER);
expect(getItemSale1Line(activeOrder!.lines).discounts.length).toBe(0);
expect(activeOrder!.total).toBe(2200);
expect(activeOrder!.totalWithTax).toBe(2640);
Expand Down Expand Up @@ -986,9 +1042,8 @@ describe('Promotions applied to Orders', () => {
expect(removeCouponCode!.total).toBe(2200);
expect(removeCouponCode!.totalWithTax).toBe(2640);

const { activeOrder } = await shopClient.query<CodegenShop.GetActiveOrderQuery>(
GET_ACTIVE_ORDER,
);
const { activeOrder } =
await shopClient.query<CodegenShop.GetActiveOrderQuery>(GET_ACTIVE_ORDER);
expect(getItemSale1Line(activeOrder!.lines).discounts.length).toBe(0);
expect(activeOrder!.total).toBe(2200);
expect(activeOrder!.totalWithTax).toBe(2640);
Expand Down Expand Up @@ -1534,9 +1589,8 @@ describe('Promotions applied to Orders', () => {

await addGuestCustomerToOrder();

const { activeOrder } = await shopClient.query<CodegenShop.GetActiveOrderQuery>(
GET_ACTIVE_ORDER,
);
const { activeOrder } =
await shopClient.query<CodegenShop.GetActiveOrderQuery>(GET_ACTIVE_ORDER);
expect(activeOrder!.couponCodes).toEqual([]);
expect(activeOrder!.totalWithTax).toBe(6000);
});
Expand Down Expand Up @@ -1627,9 +1681,8 @@ describe('Promotions applied to Orders', () => {

await logInAsRegisteredCustomer();

const { activeOrder } = await shopClient.query<CodegenShop.GetActiveOrderQuery>(
GET_ACTIVE_ORDER,
);
const { activeOrder } =
await shopClient.query<CodegenShop.GetActiveOrderQuery>(GET_ACTIVE_ORDER);
expect(activeOrder!.totalWithTax).toBe(6000);
expect(activeOrder!.couponCodes).toEqual([]);
});
Expand Down Expand Up @@ -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<CodegenShop.GetActiveOrderQuery>(
GET_ACTIVE_ORDER,
);
const { activeOrder: check1 } =
await shopClient.query<CodegenShop.GetActiveOrderQuery>(GET_ACTIVE_ORDER);
expect(check1!.discounts.length).toBe(1);
expect(check1!.discounts[0].description).toBe('Test Promo');

Expand All @@ -1899,9 +1951,8 @@ describe('Promotions applied to Orders', () => {
orderResultGuard.assertSuccess(removeOrderLine);
expect(removeOrderLine.discounts.length).toBe(0);

const { activeOrder: check2 } = await shopClient.query<CodegenShop.GetActiveOrderQuery>(
GET_ACTIVE_ORDER,
);
const { activeOrder: check2 } =
await shopClient.query<CodegenShop.GetActiveOrderQuery>(GET_ACTIVE_ORDER);
expect(check2!.discounts.length).toBe(0);
});

Expand Down Expand Up @@ -2043,9 +2094,8 @@ describe('Promotions applied to Orders', () => {
quantity: 1,
});

const { activeOrder: check1 } = await shopClient.query<CodegenShop.GetActiveOrderQuery>(
GET_ACTIVE_ORDER,
);
const { activeOrder: check1 } =
await shopClient.query<CodegenShop.GetActiveOrderQuery>(GET_ACTIVE_ORDER);

expect(check1!.lines[0].discountedUnitPriceWithTax).toBe(0);
expect(check1!.totalWithTax).toBe(0);
Expand All @@ -2055,9 +2105,8 @@ describe('Promotions applied to Orders', () => {
CodegenShop.ApplyCouponCodeMutationVariables
>(APPLY_COUPON_CODE, { couponCode: couponCode2 });

const { activeOrder: check2 } = await shopClient.query<CodegenShop.GetActiveOrderQuery>(
GET_ACTIVE_ORDER,
);
const { activeOrder: check2 } =
await shopClient.query<CodegenShop.GetActiveOrderQuery>(GET_ACTIVE_ORDER);
expect(check2!.lines[0].discountedUnitPriceWithTax).toBe(0);
expect(check2!.totalWithTax).toBe(0);
});
Expand All @@ -2080,9 +2129,8 @@ describe('Promotions applied to Orders', () => {
quantity: 1,
});

const { activeOrder: check1 } = await shopClient.query<CodegenShop.GetActiveOrderQuery>(
GET_ACTIVE_ORDER,
);
const { activeOrder: check1 } =
await shopClient.query<CodegenShop.GetActiveOrderQuery>(GET_ACTIVE_ORDER);

expect(check1!.lines[0].discountedUnitPriceWithTax).toBe(0);
expect(check1!.totalWithTax).toBe(0);
Expand All @@ -2092,9 +2140,8 @@ describe('Promotions applied to Orders', () => {
CodegenShop.ApplyCouponCodeMutationVariables
>(APPLY_COUPON_CODE, { couponCode: couponCode2 });

const { activeOrder: check2 } = await shopClient.query<CodegenShop.GetActiveOrderQuery>(
GET_ACTIVE_ORDER,
);
const { activeOrder: check2 } =
await shopClient.query<CodegenShop.GetActiveOrderQuery>(GET_ACTIVE_ORDER);
expect(check2!.lines[0].discountedUnitPriceWithTax).toBe(0);
expect(check2!.totalWithTax).toBe(0);
});
Expand Down
Original file line number Diff line number Diff line change
@@ -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' }],
});
2 changes: 2 additions & 0 deletions packages/core/src/config/promotion/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -27,6 +28,7 @@ export * from './utils/facet-value-checker';

export const defaultPromotionActions = [
orderFixedDiscount,
orderLineFixedDiscount,
orderPercentageDiscount,
discountOnItemWithFacets,
productsPercentageDiscount,
Expand Down
91 changes: 90 additions & 1 deletion packages/core/src/config/promotion/promotion-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -77,6 +77,22 @@ export type ExecutePromotionItemActionFn<T extends ConfigArgs, U extends Array<P
promotion: Promotion,
) => number | Promise<number>;

/**
* @description
* The function which is used by a PromotionLineAction to calculate the
* discount on the OrderLine.
*
* @docsCategory promotions
* @docsPage promotion-action
*/
export type ExecutePromotionLineActionFn<T extends ConfigArgs, U extends Array<PromotionCondition<any>>> = (
ctx: RequestContext,
orderLine: OrderLine,
args: ConfigArgValues<T>,
state: ConditionState<U>,
promotion: Promotion,
) => number | Promise<number>;

/**
* @description
* The function which is used by a PromotionOrderAction to calculate the
Expand Down Expand Up @@ -201,6 +217,24 @@ export interface PromotionItemActionConfig<T extends ConfigArgs, U extends Promo
execute: ExecutePromotionItemActionFn<T, U>;
}

/**
* @description
* Configuration for a {@link PromotionLineAction}
*
* @docsCategory promotions
* @docsPage promotion-action
*/
export interface PromotionLineActionConfig<T extends ConfigArgs, U extends PromotionCondition[]>
extends PromotionActionConfig<T, U> {
/**
* @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<T, U>;
}

/**
* @description
*
Expand Down Expand Up @@ -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<PromotionCondition<any>> = [],
> extends PromotionAction<T, U> {
private readonly executeFn: ExecutePromotionLineActionFn<T, U>;
constructor(config: PromotionLineActionConfig<T, U>) {
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<U>,
promotion,
);
}
}

/**
* @description
* Represents a PromotionAction which applies to the {@link Order} as a whole.
Expand Down
Loading
Loading