From 3e2cc2bbdc83ff9901f38af30d6d37201550eeb2 Mon Sep 17 00:00:00 2001 From: Michael Bromley Date: Fri, 15 May 2020 16:50:58 +0200 Subject: [PATCH] feat(core): Implement configurable PriceCalculationStrategy Relates to #237 --- packages/core/src/app.module.ts | 3 +- packages/core/src/config/default-config.ts | 2 + packages/core/src/config/index.ts | 1 + .../default-price-calculation-strategy.ts | 14 ++++ .../order/price-calculation-strategy.ts | 60 ++++++++++++++++ .../src/config/promotion/promotion-action.ts | 42 +++++++++++ .../config/promotion/promotion-condition.ts | 3 + packages/core/src/config/vendure-config.ts | 9 +++ .../src/service/services/order.service.ts | 72 ++++++++++--------- 9 files changed, 172 insertions(+), 34 deletions(-) create mode 100644 packages/core/src/config/order/default-price-calculation-strategy.ts create mode 100644 packages/core/src/config/order/price-calculation-strategy.ts diff --git a/packages/core/src/app.module.ts b/packages/core/src/app.module.ts index ad63d76f0d..f70e51b303 100644 --- a/packages/core/src/app.module.ts +++ b/packages/core/src/app.module.ts @@ -131,7 +131,7 @@ export class AppModule implements NestModule, OnApplicationBootstrap, OnApplicat } = this.configService.assetOptions; const { taxCalculationStrategy, taxZoneStrategy } = this.configService.taxOptions; const { jobQueueStrategy } = this.configService.jobQueueOptions; - const { mergeStrategy } = this.configService.orderOptions; + const { mergeStrategy, priceCalculationStrategy } = this.configService.orderOptions; const { entityIdStrategy } = this.configService; return [ assetNamingStrategy, @@ -142,6 +142,7 @@ export class AppModule implements NestModule, OnApplicationBootstrap, OnApplicat jobQueueStrategy, mergeStrategy, entityIdStrategy, + priceCalculationStrategy, ]; } diff --git a/packages/core/src/config/default-config.ts b/packages/core/src/config/default-config.ts index 6b7a20a613..9d485bd134 100644 --- a/packages/core/src/config/default-config.ts +++ b/packages/core/src/config/default-config.ts @@ -12,6 +12,7 @@ import { defaultCollectionFilters } from './collection/default-collection-filter import { AutoIncrementIdStrategy } from './entity-id-strategy/auto-increment-id-strategy'; import { DefaultLogger } from './logger/default-logger'; import { TypeOrmLogger } from './logger/typeorm-logger'; +import { DefaultPriceCalculationStrategy } from './order/default-price-calculation-strategy'; import { MergeOrdersStrategy } from './order/merge-orders-strategy'; import { UseGuestStrategy } from './order/use-guest-strategy'; import { defaultPromotionActions } from './promotion/default-promotion-actions'; @@ -83,6 +84,7 @@ export const defaultConfig: RuntimeVendureConfig = { }, orderOptions: { orderItemsLimit: 999, + priceCalculationStrategy: new DefaultPriceCalculationStrategy(), mergeStrategy: new MergeOrdersStrategy(), checkoutMergeStrategy: new UseGuestStrategy(), process: {}, diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts index e7c4829b10..adbabd05f7 100644 --- a/packages/core/src/config/index.ts +++ b/packages/core/src/config/index.ts @@ -16,6 +16,7 @@ export * from './logger/noop-logger'; export * from './logger/vendure-logger'; export * from './merge-config'; export * from './order/order-merge-strategy'; +export * from './order/price-calculation-strategy'; export * from './payment-method/example-payment-method-handler'; export * from './payment-method/payment-method-handler'; export * from './promotion/default-promotion-actions'; diff --git a/packages/core/src/config/order/default-price-calculation-strategy.ts b/packages/core/src/config/order/default-price-calculation-strategy.ts new file mode 100644 index 0000000000..4c0b9b178a --- /dev/null +++ b/packages/core/src/config/order/default-price-calculation-strategy.ts @@ -0,0 +1,14 @@ +import { ProductVariant } from '../../entity/product-variant/product-variant.entity'; + +import { CalculatedPrice, PriceCalculationStrategy } from './price-calculation-strategy'; + +/** + * @description + * The default {@link PriceCalculationStrategy}, which simply passes through the price of + * the ProductVariant without performing any calculations + */ +export class DefaultPriceCalculationStrategy implements PriceCalculationStrategy { + calculateUnitPrice(productVariant: ProductVariant): CalculatedPrice | Promise { + return productVariant; + } +} diff --git a/packages/core/src/config/order/price-calculation-strategy.ts b/packages/core/src/config/order/price-calculation-strategy.ts new file mode 100644 index 0000000000..035a7e5f7b --- /dev/null +++ b/packages/core/src/config/order/price-calculation-strategy.ts @@ -0,0 +1,60 @@ +import { InjectableStrategy } from '../../common/types/injectable-strategy'; +import { ProductVariant } from '../../entity/product-variant/product-variant.entity'; + +/** + * @description + * The result of the price calculation from the {@link PriceCalculationStrategy}. + * + * @docsCateogory Orders + */ +export type CalculatedPrice = { + price: number; + priceIncludesTax: boolean; +}; + +/** + * @description + * The PriceCalculationStrategy defines the price of an OrderItem when a ProductVariant gets added + * to an order via the `addItemToOrder` mutation. By default the {@link DefaultPriceCalculationStrategy} + * is used. + * + * ### PriceCalculationStrategy vs Promotions + * Both the PriceCalculationStrategy and Promotions can be used to alter the price paid for a product. + * + * Use PriceCalculationStrategy if: + * + * * The price is not dependent on quantity or on the other contents of the Order. + * * The price calculation is based on the properties of the ProductVariant and any CustomFields + * specified on the OrderLine, for example via a product configurator. + * * The logic is a permanent part of your business requirements. + * + * Use Promotions if: + * + * * You want to implement "discounts" and "special offers" + * * The calculation is not a permanent part of your business requirements. + * * The price depends on dynamic aspects such as quantities and which other + * ProductVariants are in the Order. + * * The configuration of the logic needs to be manipulated via the Admin UI. + * + * ### Example use-cases + * + * A custom PriceCalculationStrategy can be used to implement things like: + * + * * A gift-wrapping service, where a boolean custom field is defined on the OrderLine. If `true`, + * a gift-wrapping surcharge would be added to the price. + * * A product-configurator where e.g. various finishes, colors, and materials can be selected and stored + * as OrderLine custom fields. + * + * @docsCateogory Orders + */ +export interface PriceCalculationStrategy extends InjectableStrategy { + /** + * @description + * Receives the ProductVariant to be added to the Order as well as any OrderLine custom fields and returns + * the price for a single unit. + */ + calculateUnitPrice( + productVariant: ProductVariant, + orderLineCustomFields: { [key: string]: any }, + ): CalculatedPrice | Promise; +} diff --git a/packages/core/src/config/promotion/promotion-action.ts b/packages/core/src/config/promotion/promotion-action.ts index 0023a7261e..2f744d16e7 100644 --- a/packages/core/src/config/promotion/promotion-action.ts +++ b/packages/core/src/config/promotion/promotion-action.ts @@ -17,12 +17,29 @@ import { PromotionUtils } from './promotion-condition'; export type PromotionActionArgType = ConfigArgSubset<'int' | 'facetValueIds'>; export type PromotionActionArgs = ConfigArgs; +/** + * @description + * The function which is used by a PromotionItemAction to calculate the + * discount on the OrderItem. + * + * @docsCategory promotions + * @docsPage promotion-action + */ export type ExecutePromotionItemActionFn = ( orderItem: OrderItem, orderLine: OrderLine, args: ConfigArgValues, utils: PromotionUtils, ) => number | Promise; + +/** + * @description + * The function which is used by a PromotionOrderAction to calculate the + * discount on the Order. + * + * @docsCategory promotions + * @docsPage promotion-action + */ export type ExecutePromotionOrderActionFn = ( order: Order, args: ConfigArgValues, @@ -33,10 +50,32 @@ export interface PromotionActionConfig extends ConfigurableOperationDefOptions { priorityValue?: number; } + +/** + * @description + * + * @docsCategory promotions + * @docsPage promotion-action + */ export interface PromotionItemActionConfig extends PromotionActionConfig { + /** + * @description + * The function which contains the promotion calculation logic. + */ execute: ExecutePromotionItemActionFn; } + +/** + * @description + * + * @docsCategory promotions + * @docsPage promotion-action + */ export interface PromotionOrderActionConfig extends PromotionActionConfig { + /** + * @description + * The function which contains the promotion calculation logic. + */ execute: ExecutePromotionOrderActionFn; } @@ -45,6 +84,7 @@ export interface PromotionOrderActionConfig exten * An abstract class which is extended by {@link PromotionItemAction} and {@link PromotionOrderAction}. * * @docsCategory promotions + * @docsPage promotion-action */ export abstract class PromotionAction extends ConfigurableOperationDef< T @@ -75,6 +115,7 @@ export abstract class PromotionAction extend * ``` * * @docsCategory promotions + * @docsPage promotion-action */ export class PromotionItemAction extends PromotionAction { private readonly executeFn: ExecutePromotionItemActionFn; @@ -107,6 +148,7 @@ export class PromotionItemAction extends Pro * ``` * * @docsCategory promotions + * @docsPage promotion-action */ export class PromotionOrderAction extends PromotionAction { private readonly executeFn: ExecutePromotionOrderActionFn; diff --git a/packages/core/src/config/promotion/promotion-condition.ts b/packages/core/src/config/promotion/promotion-condition.ts index 7ba4ec14d8..93488f7b3a 100644 --- a/packages/core/src/config/promotion/promotion-condition.ts +++ b/packages/core/src/config/promotion/promotion-condition.ts @@ -25,6 +25,7 @@ export type PromotionConditionArgs = ConfigArgs; * TODO: Remove this and use the new init() method to inject providers where needed. * * @docsCategory promotions + * @docsPage promotion-condition */ export interface PromotionUtils { /** @@ -41,6 +42,7 @@ export interface PromotionUtils { * A function which checks whether or not a given {@link Order} satisfies the {@link PromotionCondition}. * * @docsCategory promotions + * @docsPage promotion-condition */ export type CheckPromotionConditionFn = ( order: Order, @@ -61,6 +63,7 @@ export interface PromotionConditionConfig * `true` if the Order satisfies the condition, or `false` if it does not. * * @docsCategory promotions + * @docsPage promotion-condition */ export class PromotionCondition extends ConfigurableOperationDef { readonly priorityValue: number; diff --git a/packages/core/src/config/vendure-config.ts b/packages/core/src/config/vendure-config.ts index 2c23c9d4ee..9378767783 100644 --- a/packages/core/src/config/vendure-config.ts +++ b/packages/core/src/config/vendure-config.ts @@ -21,6 +21,7 @@ import { EntityIdStrategy } from './entity-id-strategy/entity-id-strategy'; import { JobQueueStrategy } from './job-queue/job-queue-strategy'; import { VendureLogger } from './logger/vendure-logger'; import { OrderMergeStrategy } from './order/order-merge-strategy'; +import { PriceCalculationStrategy } from './order/price-calculation-strategy'; import { PaymentMethodHandler } from './payment-method/payment-method-handler'; import { PromotionAction } from './promotion/promotion-action'; import { PromotionCondition } from './promotion/promotion-condition'; @@ -238,6 +239,14 @@ export interface OrderOptions { * @default 999 */ orderItemsLimit?: number; + /** + * @description + * Defines the logic used to calculate the unit price of an OrderItem when adding an + * item to an Order. + * + * @default DefaultPriceCalculationStrategy + */ + priceCalculationStrategy?: PriceCalculationStrategy; /** * @description * Defines custom states and transition logic for the order process state machine. diff --git a/packages/core/src/service/services/order.service.ts b/packages/core/src/service/services/order.service.ts index bfef3f62fe..8dfd87980d 100644 --- a/packages/core/src/service/services/order.service.ts +++ b/packages/core/src/service/services/order.service.ts @@ -113,7 +113,7 @@ export class OrderService { .addOrderBy('items.createdAt', 'ASC') .getOne(); if (order) { - order.lines.forEach(line => { + order.lines.forEach((line) => { line.productVariant = translateDeep( this.productVariantService.applyChannelPriceAndTax(line.productVariant, ctx), ctx.languageCode, @@ -145,8 +145,8 @@ export class OrderService { .andWhere('order.customer.id = :customerId', { customerId }) .getManyAndCount() .then(([items, totalItems]) => { - items.forEach(item => { - item.lines.forEach(line => { + items.forEach((item) => { + item.lines.forEach((line) => { line.productVariant = translateDeep(line.productVariant, ctx.languageCode, [ 'options', ]); @@ -231,7 +231,7 @@ export class OrderService { this.assertAddingItemsState(order); this.assertNotOverOrderItemsLimit(order, quantity); const productVariant = await this.getProductVariantOrThrow(ctx, productVariantId); - let orderLine = order.lines.find(line => { + let orderLine = order.lines.find((line) => { return ( idsAreEqual(line.productVariant.id, productVariantId) && JSON.stringify(line.customFields) === JSON.stringify(customFields) @@ -254,11 +254,15 @@ export class OrderService { quantity?: number | null, customFields?: { [key: string]: any }, ): Promise { + const { priceCalculationStrategy } = this.configService.orderOptions; const order = orderIdOrOrder instanceof Order ? orderIdOrOrder : await this.getOrderOrThrow(ctx, orderIdOrOrder); const orderLine = this.getOrderLineOrThrow(order, orderLineId); + if (customFields != null) { + orderLine.customFields = customFields; + } this.assertAddingItemsState(order); if (quantity != null) { this.assertQuantityIsPositive(quantity); @@ -269,12 +273,16 @@ export class OrderService { orderLine.items = []; } const productVariant = orderLine.productVariant; + const calculatedPrice = await priceCalculationStrategy.calculateUnitPrice( + productVariant, + orderLine.customFields || {}, + ); for (let i = currentQuantity; i < quantity; i++) { const orderItem = await this.connection.getRepository(OrderItem).save( new OrderItem({ - unitPrice: productVariant.price, + unitPrice: calculatedPrice.price, pendingAdjustments: [], - unitPriceIncludesTax: productVariant.priceIncludesTax, + unitPriceIncludesTax: calculatedPrice.priceIncludesTax, taxRate: productVariant.priceIncludesTax ? productVariant.taxRateApplied.value : 0, @@ -286,9 +294,6 @@ export class OrderService { orderLine.items = orderLine.items.slice(0, quantity); } } - if (customFields != null) { - orderLine.customFields = customFields; - } await this.connection.getRepository(OrderLine).save(orderLine, { reload: false }); return this.applyPriceAdjustments(ctx, order, orderLine); } @@ -297,7 +302,7 @@ export class OrderService { const order = await this.getOrderOrThrow(ctx, orderId); this.assertAddingItemsState(order); const orderLine = this.getOrderLineOrThrow(order, orderLineId); - order.lines = order.lines.filter(line => !idsAreEqual(line.id, orderLineId)); + order.lines = order.lines.filter((line) => !idsAreEqual(line.id, orderLineId)); const updatedOrder = await this.applyPriceAdjustments(ctx, order); await this.connection.getRepository(OrderLine).remove(orderLine); return updatedOrder; @@ -325,7 +330,7 @@ export class OrderService { async removeCouponCode(ctx: RequestContext, orderId: ID, couponCode: string) { const order = await this.getOrderOrThrow(ctx, orderId); if (order.couponCodes.includes(couponCode)) { - order.couponCodes = order.couponCodes.filter(cc => cc !== couponCode); + order.couponCodes = order.couponCodes.filter((cc) => cc !== couponCode); await this.historyService.createHistoryEntryForOrder({ ctx, orderId: order.id, @@ -359,7 +364,7 @@ export class OrderService { async getEligibleShippingMethods(ctx: RequestContext, orderId: ID): Promise { const order = await this.getOrderOrThrow(ctx, orderId); const eligibleMethods = await this.shippingCalculator.getEligibleShippingMethods(ctx, order); - return eligibleMethods.map(eligible => ({ + return eligibleMethods.map((eligible) => ({ id: eligible.method.id as string, price: eligible.result.price, priceWithTax: eligible.result.priceWithTax, @@ -372,7 +377,7 @@ export class OrderService { const order = await this.getOrderOrThrow(ctx, orderId); this.assertAddingItemsState(order); const eligibleMethods = await this.shippingCalculator.getEligibleShippingMethods(ctx, order); - const selectedMethod = eligibleMethods.find(m => idsAreEqual(m.method.id, shippingMethodId)); + const selectedMethod = eligibleMethods.find((m) => idsAreEqual(m.method.id, shippingMethodId)); if (!selectedMethod) { throw new UserInputError(`error.shipping-method-unavailable`); } @@ -413,7 +418,7 @@ export class OrderService { function totalIsCovered(state: PaymentState): boolean { return ( - order.payments.filter(p => p.state === state).reduce((sum, p) => sum + p.amount, 0) === + order.payments.filter((p) => p.state === state).reduce((sum, p) => sum + p.amount, 0) === order.total ); } @@ -456,7 +461,7 @@ export class OrderService { } const { items, orders } = await this.getOrdersAndItemsFromLines( input.lines, - i => !i.fulfillment, + (i) => !i.fulfillment, 'error.create-fulfillment-items-already-fulfilled', ); @@ -491,8 +496,8 @@ export class OrderService { } const allOrderItemsFulfilled = orderWithFulfillments.lines .reduce((orderItems, line) => [...orderItems, ...line.items], [] as OrderItem[]) - .filter(orderItem => !orderItem.cancelled) - .every(orderItem => { + .filter((orderItem) => !orderItem.cancelled) + .every((orderItem) => { return !!orderItem.fulfillment; }); if (allOrderItemsFulfilled) { @@ -522,7 +527,7 @@ export class OrderService { }); } const items = lines.reduce((acc, l) => [...acc, ...l.items], [] as OrderItem[]); - return unique(items.map(i => i.fulfillment).filter(notNullOrUndefined), 'id'); + return unique(items.map((i) => i.fulfillment).filter(notNullOrUndefined), 'id'); } async getFulfillmentOrderItems(id: ID): Promise { @@ -550,7 +555,7 @@ export class OrderService { if (order.state === 'AddingItems' || order.state === 'ArrangingPayment') { return true; } else { - const lines: OrderLineInput[] = order.lines.map(l => ({ + const lines: OrderLineInput[] = order.lines.map((l) => ({ orderLineId: l.id as string, quantity: l.quantity, })); @@ -568,7 +573,7 @@ export class OrderService { } const { items, orders } = await this.getOrdersAndItemsFromLines( lines, - i => !i.cancelled, + (i) => !i.cancelled, 'error.cancel-order-lines-quantity-too-high', ); if (1 < orders.length) { @@ -586,7 +591,7 @@ export class OrderService { // Perform the cancellation await this.stockMovementService.createCancellationsForOrderItems(items); - items.forEach(i => (i.cancelled = true)); + items.forEach((i) => (i.cancelled = true)); await this.connection.getRepository(OrderItem).save(items, { reload: false }); const orderWithItems = await this.connection.getRepository(Order).findOne(order.id, { @@ -600,13 +605,13 @@ export class OrderService { orderId: order.id, type: HistoryEntryType.ORDER_CANCELLATION, data: { - orderItemIds: items.map(i => i.id), + orderItemIds: items.map((i) => i.id), reason: input.reason || undefined, }, }); const allOrderItemsCancelled = orderWithItems.lines .reduce((orderItems, line) => [...orderItems, ...line.items], [] as OrderItem[]) - .every(orderItem => orderItem.cancelled); + .every((orderItem) => orderItem.cancelled); return allOrderItemsCancelled; } @@ -621,7 +626,7 @@ export class OrderService { } const { items, orders } = await this.getOrdersAndItemsFromLines( input.lines, - i => !i.cancelled, + (i) => !i.cancelled, 'error.refund-order-lines-quantity-too-high', ); if (1 < orders.length) { @@ -643,7 +648,7 @@ export class OrderService { state: order.state, }); } - if (items.some(i => !!i.refundId)) { + if (items.some((i) => !!i.refundId)) { throw new IllegalOperationError('error.refund-order-item-already-refunded'); } @@ -680,7 +685,7 @@ export class OrderService { try { await this.promotionService.validateCouponCode(couponCode, customer.id); } catch (err) { - order.couponCodes = order.couponCodes.filter(c => c !== couponCode); + order.couponCodes = order.couponCodes.filter((c) => c !== couponCode); codesRemoved = true; } } @@ -761,7 +766,7 @@ export class OrderService { } private getOrderLineOrThrow(order: Order, orderLineId: ID): OrderLine { - const orderItem = order.lines.find(line => idsAreEqual(line.id, orderLineId)); + const orderItem = order.lines.find((line) => idsAreEqual(line.id, orderLineId)); if (!orderItem) { throw new UserInputError(`error.order-does-not-contain-line-with-id`, { id: orderLineId }); } @@ -841,14 +846,15 @@ export class OrderService { const orders = new Map(); const items = new Map(); - const lines = await this.connection - .getRepository(OrderLine) - .findByIds(orderLinesInput.map(l => l.orderLineId), { + const lines = await this.connection.getRepository(OrderLine).findByIds( + orderLinesInput.map((l) => l.orderLineId), + { relations: ['order', 'items', 'items.fulfillment'], order: { id: 'ASC' }, - }); + }, + ); for (const line of lines) { - const inputLine = orderLinesInput.find(l => idsAreEqual(l.orderLineId, line.id)); + const inputLine = orderLinesInput.find((l) => idsAreEqual(l.orderLineId, line.id)); if (!inputLine) { continue; } @@ -860,7 +866,7 @@ export class OrderService { if (matchingItems.length < inputLine.quantity) { throw new IllegalOperationError(noMatchesError); } - matchingItems.slice(0, inputLine.quantity).forEach(item => { + matchingItems.slice(0, inputLine.quantity).forEach((item) => { items.set(item.id, item); }); }