diff --git a/packages/core/e2e/fixtures/test-order-item-price-calculation-strategy.ts b/packages/core/e2e/fixtures/test-order-item-price-calculation-strategy.ts index f10c148fc5..1437c5f261 100644 --- a/packages/core/e2e/fixtures/test-order-item-price-calculation-strategy.ts +++ b/packages/core/e2e/fixtures/test-order-item-price-calculation-strategy.ts @@ -1,6 +1,6 @@ import { - CalculatedPrice, OrderItemPriceCalculationStrategy, + PriceCalculationResult, ProductVariant, RequestContext, } from '@vendure/core'; @@ -13,7 +13,7 @@ export class TestOrderItemPriceCalculationStrategy implements OrderItemPriceCalc ctx: RequestContext, productVariant: ProductVariant, orderLineCustomFields: { [p: string]: any }, - ): CalculatedPrice | Promise { + ): PriceCalculationResult | Promise { let price = productVariant.price; if (orderLineCustomFields.giftWrap) { price += 500; diff --git a/packages/core/src/service/helpers/variant-calculator/variant-calculator.spec.ts b/packages/core/src/service/helpers/variant-calculator/variant-calculator.spec.ts new file mode 100644 index 0000000000..a83bc01574 --- /dev/null +++ b/packages/core/src/service/helpers/variant-calculator/variant-calculator.spec.ts @@ -0,0 +1,135 @@ +import { Test } from '@nestjs/testing'; +import { LanguageCode } from '@vendure/common/lib/generated-types'; + +import { TestOrderItemPriceCalculationStrategy } from '../../../../e2e/fixtures/test-order-item-price-calculation-strategy'; +import { RequestContext } from '../../../api'; +import { RequestContextCacheService } from '../../../cache'; +import { + ConfigService, + DefaultTaxLineCalculationStrategy, + DefaultTaxZoneStrategy, + PromotionCondition, + PromotionItemAction, + ShippingCalculator, +} from '../../../config'; +import { DefaultProductVariantPriceCalculationStrategy } from '../../../config/catalog/default-product-variant-price-calculation-strategy'; +import { MockConfigService } from '../../../config/config.service.mock'; +import { Order, ProductVariant, Promotion } from '../../../entity'; +import { EventBus } from '../../../event-bus'; +import { createRequestContext, MockTaxRateService } from '../../../testing/order-test-utils'; +import { CustomerService } from '../../services/customer.service'; +import { OrderService } from '../../services/order.service'; +import { ShippingMethodService } from '../../services/shipping-method.service'; +import { TaxRateService } from '../../services/tax-rate.service'; +import { ZoneService } from '../../services/zone.service'; +import { ListQueryBuilder } from '../list-query-builder/list-query-builder'; +import { OrderCalculator } from '../order-calculator/order-calculator'; +import { ProductPriceApplicator } from '../product-price-applicator/product-price-applicator'; + +import { VariantCalculator } from './variant-calculator'; + +describe('OrderCalculator', () => { + let variantCalculator: VariantCalculator; + + beforeAll(async () => { + const module = await createTestModule(); + variantCalculator = module.get(VariantCalculator); + const mockConfigService = module.get(ConfigService); + mockConfigService.taxOptions = { + taxZoneStrategy: new DefaultTaxZoneStrategy(), + taxLineCalculationStrategy: new DefaultTaxLineCalculationStrategy(), + }; + mockConfigService.orderOptions = { + orderItemPriceCalculationStrategy: new TestOrderItemPriceCalculationStrategy(), + }; + mockConfigService.catalogOptions = { + productVariantPriceCalculationStrategy: new DefaultProductVariantPriceCalculationStrategy(), + }; + }); + + describe('variant price calculator', () => { + it('calculates the correct price for provided order', async () => { + const ctx = createRequestContext({ pricesIncludeTax: false }); + const order = new Order({ active: true, shippingAddress: {}, customer: { id: 1 } }); + const variant = new ProductVariant({ + listPrice: 50, + productVariantPrices: [{ channelId: 1, price: 50 }], + taxCategory: { id: 1 }, + }); + + const alwaysTrueCondition = new PromotionCondition({ + args: {}, + code: 'always_true_condition', + description: [{ languageCode: LanguageCode.en, value: '' }], + check() { + return true; + }, + }); + + const fixedPriceItemAction = new PromotionItemAction({ + code: 'fixed_price_item_action', + description: [{ languageCode: LanguageCode.en, value: '' }], + args: {}, + execute(context, item) { + return -8; + }, + }); + + const promotion = new Promotion({ + conditions: [{ code: alwaysTrueCondition.code, args: [] }], + promotionConditions: [alwaysTrueCondition], + actions: [ + { + code: fixedPriceItemAction.code, + args: [], + }, + ], + promotionActions: [fixedPriceItemAction], + }); + + const calculated = await variantCalculator.applyVariantPromotions( + ctx, + variant, + [promotion], + 1, + order, + ); + expect(calculated).toBe(50 - 8); + }); + }); +}); + +function createTestModule() { + return Test.createTestingModule({ + providers: [ + VariantCalculator, + ProductPriceApplicator, + RequestContextCacheService, + { provide: TaxRateService, useClass: MockTaxRateService }, + { provide: ShippingCalculator, useValue: { getEligibleShippingMethods: () => [] } }, + { + provide: ShippingMethodService, + useValue: { + findOne: (ctx: RequestContext) => ({ + id: 'T_2', + test: () => true, + apply() { + return { + price: 500, + priceIncludesTax: ctx.channel.pricesIncludeTax, + taxRate: 20, + }; + }, + }), + }, + }, + { provide: ListQueryBuilder, useValue: {} }, + { provide: ConfigService, useClass: MockConfigService }, + { provide: EventBus, useValue: { publish: () => ({}) } }, + { provide: ZoneService, useValue: { findAll: () => [] } }, + { provide: CustomerService, useValue: {} }, + { provide: OrderService, useValue: {} }, + OrderCalculator, + ], + }).compile(); +} diff --git a/packages/core/src/service/helpers/variant-calculator/variant-calculator.ts b/packages/core/src/service/helpers/variant-calculator/variant-calculator.ts new file mode 100644 index 0000000000..2b4b4899ab --- /dev/null +++ b/packages/core/src/service/helpers/variant-calculator/variant-calculator.ts @@ -0,0 +1,128 @@ +import { Injectable } from '@nestjs/common'; +import { CreateAddressInput } from '@vendure/common/lib/generated-types'; + +import { RequestContext } from '../../../api'; +import { InternalServerError } from '../../../common'; +import { ConfigService } from '../../../config'; +import { Order, OrderItem, OrderLine, ProductVariant, Promotion, ShippingLine } from '../../../entity'; +import { CustomerService } from '../../services/customer.service'; +import { OrderService } from '../../services/order.service'; +import { OrderCalculator } from '../order-calculator/order-calculator'; +import { ProductPriceApplicator } from '../product-price-applicator/product-price-applicator'; + +@Injectable() +export class VariantCalculator { + constructor( + private configService: ConfigService, + private orderService: OrderService, + private customerService: CustomerService, + private productPriceApplicator: ProductPriceApplicator, + private orderCalculator: OrderCalculator, + ) {} + + /** + * @description adds the variant to a new order based on `initialOrder` and applies all `promotions`, + * + * if no `initialOrder` is supplied, it is based on the users active order + */ + public async applyVariantPromotions( + ctx: RequestContext, + variant: ProductVariant, + promotions: Promotion[], + quantity = 1, + initialOrder?: Order, + ) { + const customerId = initialOrder?.customer?.id ?? ctx.activeUserId; + if (!customerId) { + throw new InternalServerError('no active user or order customer'); + } + + const { orderItemPriceCalculationStrategy } = this.configService.orderOptions; + if (!orderItemPriceCalculationStrategy) { + throw new InternalServerError('no item price calculation strategy found'); + } + + let order: Order; + if (initialOrder) { + order = new Order(initialOrder); + } else if (ctx.session?.activeOrderId) { + order = (await this.orderService.findOne(ctx, ctx.session?.activeOrderId)) ?? new Order(); + } else { + order = new Order(); + } + order.lines ??= []; + order.surcharges ??= []; + order.modifications ??= []; + + if (!order.customer) { + order.customer = await this.customerService.findOne(ctx, customerId); + } + + if (!order.shippingAddress) { + const addrs = await this.customerService.findAddressesByCustomerId(ctx, customerId); + // FIXME use correct Error type + if (addrs.length === 0) throw new Error(`no Address for Customer ${customerId} found`); + + const shippingAddress = addrs.find(x => x.defaultShippingAddress) ?? addrs[0]; + const billingAddress = addrs.find(x => x.defaultBillingAddress) ?? addrs[0]; + + order.shippingAddress = { + ...shippingAddress, + countryCode: shippingAddress.country.code, + } as CreateAddressInput; + order.billingAddress = { + ...billingAddress, + countryCode: billingAddress.country.code, + } as CreateAddressInput; + } + + const adjustedVariant = await this.productPriceApplicator.applyChannelPriceAndTax( + variant, + ctx, + order, + ); + const orderLine = new OrderLine({ + productVariant: variant, + items: [], + taxCategory: variant.taxCategory, + }); + + const { price, priceIncludesTax } = await orderItemPriceCalculationStrategy.calculateUnitPrice( + ctx, + adjustedVariant, + {}, + ); + const taxRate = adjustedVariant.taxRateApplied; + const unitPrice = priceIncludesTax ? taxRate.netPriceOf(price) : price; + + for (let i = 0; i < quantity; i++) { + const orderItem = new OrderItem({ + id: adjustedVariant.id, + listPrice: price, + listPriceIncludesTax: priceIncludesTax, + adjustments: [], + taxLines: [], + }); + orderLine.items.push(orderItem); + } + + order.shippingLines = [ + new ShippingLine({ + listPrice: 0, + listPriceIncludesTax: ctx.channel.pricesIncludeTax, + taxLines: [], + adjustments: [], + }), + ]; + + order.lines.push(orderLine); + const adjustedItems = await this.orderCalculator.applyPriceAdjustments(ctx, order, promotions); + const item = adjustedItems.find(x => x.line?.productVariant.id === variant.id); + if (!item) + // TODO figure out how to handle this case and why it can happen + // since this methods usecase is mostly to display a discounted price + // it should probably just return the original price when it can't be calculated + throw new Error('variant was not adjusted'); + return item.discountedUnitPrice; + } +} diff --git a/packages/core/src/service/service.module.ts b/packages/core/src/service/service.module.ts index a2e6613f75..efda34b881 100644 --- a/packages/core/src/service/service.module.ts +++ b/packages/core/src/service/service.module.ts @@ -26,6 +26,7 @@ import { RequestContextService } from './helpers/request-context/request-context import { ShippingCalculator } from './helpers/shipping-calculator/shipping-calculator'; import { SlugValidator } from './helpers/slug-validator/slug-validator'; import { TranslatableSaver } from './helpers/translatable-saver/translatable-saver'; +import { VariantCalculator } from './helpers/variant-calculator/variant-calculator'; import { VerificationTokenGenerator } from './helpers/verification-token-generator/verification-token-generator'; import { InitializerService } from './initializer.service'; import { AdministratorService } from './services/administrator.service'; @@ -118,6 +119,7 @@ const helpers = [ ProductPriceApplicator, EntityHydrator, RequestContextService, + VariantCalculator, ]; /** diff --git a/packages/core/src/testing/order-test-utils.ts b/packages/core/src/testing/order-test-utils.ts index 3fcd2de01b..2aa3d70d0f 100644 --- a/packages/core/src/testing/order-test-utils.ts +++ b/packages/core/src/testing/order-test-utils.ts @@ -34,6 +34,7 @@ export function createRequestContext(options: { pricesIncludeTax: boolean }): Re const channel = new Channel({ defaultTaxZone: zoneDefault, pricesIncludeTax: options.pricesIncludeTax, + id: 1, }); const ctx = new RequestContext({ apiType: 'admin',