UNPKG

@vendure/core

Version:

A modern, headless ecommerce framework

1,005 lines 99.7 kB
"use strict"; var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; var __metadata = (this && this.__metadata) || function (k, v) { if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v); }; Object.defineProperty(exports, "__esModule", { value: true }); exports.OrderService = void 0; const common_1 = require("@nestjs/common"); const generated_types_1 = require("@vendure/common/lib/generated-types"); const omit_1 = require("@vendure/common/lib/omit"); const shared_utils_1 = require("@vendure/common/lib/shared-utils"); const typeorm_1 = require("typeorm"); const FindOptionsUtils_1 = require("typeorm/find-options/FindOptionsUtils"); const request_context_cache_service_1 = require("../../cache/request-context-cache.service"); const constants_1 = require("../../common/constants"); const error_result_1 = require("../../common/error/error-result"); const errors_1 = require("../../common/error/errors"); const generated_graphql_admin_errors_1 = require("../../common/error/generated-graphql-admin-errors"); const generated_graphql_shop_errors_1 = require("../../common/error/generated-graphql-shop-errors"); const instrument_decorator_1 = require("../../common/instrument-decorator"); const tax_utils_1 = require("../../common/tax-utils"); const utils_1 = require("../../common/utils"); const config_service_1 = require("../../config/config.service"); const vendure_logger_1 = require("../../config/logger/vendure-logger"); const transactional_connection_1 = require("../../connection/transactional-connection"); const fulfillment_line_entity_1 = require("../../entity/order-line-reference/fulfillment-line.entity"); const order_line_entity_1 = require("../../entity/order-line/order-line.entity"); const order_modification_entity_1 = require("../../entity/order-modification/order-modification.entity"); const order_entity_1 = require("../../entity/order/order.entity"); const payment_entity_1 = require("../../entity/payment/payment.entity"); const product_variant_entity_1 = require("../../entity/product-variant/product-variant.entity"); const promotion_entity_1 = require("../../entity/promotion/promotion.entity"); const refund_entity_1 = require("../../entity/refund/refund.entity"); const session_entity_1 = require("../../entity/session/session.entity"); const shipping_line_entity_1 = require("../../entity/shipping-line/shipping-line.entity"); const surcharge_entity_1 = require("../../entity/surcharge/surcharge.entity"); const event_bus_1 = require("../../event-bus/event-bus"); const coupon_code_event_1 = require("../../event-bus/events/coupon-code-event"); const order_event_1 = require("../../event-bus/events/order-event"); const order_line_event_1 = require("../../event-bus/events/order-line-event"); const order_state_transition_event_1 = require("../../event-bus/events/order-state-transition-event"); const refund_event_1 = require("../../event-bus/events/refund-event"); const refund_state_transition_event_1 = require("../../event-bus/events/refund-state-transition-event"); const custom_field_relation_service_1 = require("../helpers/custom-field-relation/custom-field-relation.service"); const list_query_builder_1 = require("../helpers/list-query-builder/list-query-builder"); const order_calculator_1 = require("../helpers/order-calculator/order-calculator"); const order_merger_1 = require("../helpers/order-merger/order-merger"); const order_modifier_1 = require("../helpers/order-modifier/order-modifier"); const order_state_machine_1 = require("../helpers/order-state-machine/order-state-machine"); const refund_state_machine_1 = require("../helpers/refund-state-machine/refund-state-machine"); const shipping_calculator_1 = require("../helpers/shipping-calculator/shipping-calculator"); const translator_service_1 = require("../helpers/translator/translator.service"); const coupon_codes_match_1 = require("../helpers/utils/coupon-codes-match"); const db_errors_1 = require("../helpers/utils/db-errors"); const order_utils_1 = require("../helpers/utils/order-utils"); const patch_entity_1 = require("../helpers/utils/patch-entity"); const channel_service_1 = require("./channel.service"); const country_service_1 = require("./country.service"); const customer_service_1 = require("./customer.service"); const fulfillment_service_1 = require("./fulfillment.service"); const history_service_1 = require("./history.service"); const payment_method_service_1 = require("./payment-method.service"); const payment_service_1 = require("./payment.service"); const product_variant_service_1 = require("./product-variant.service"); const promotion_service_1 = require("./promotion.service"); const stock_level_service_1 = require("./stock-level.service"); /** * @description * Contains methods relating to {@link Order} entities. * * @docsCategory services */ let OrderService = class OrderService { constructor(connection, configService, productVariantService, customerService, countryService, orderCalculator, shippingCalculator, orderStateMachine, orderMerger, paymentService, paymentMethodService, fulfillmentService, listQueryBuilder, refundStateMachine, historyService, promotionService, eventBus, channelService, orderModifier, customFieldRelationService, requestCache, translator, stockLevelService) { this.connection = connection; this.configService = configService; this.productVariantService = productVariantService; this.customerService = customerService; this.countryService = countryService; this.orderCalculator = orderCalculator; this.shippingCalculator = shippingCalculator; this.orderStateMachine = orderStateMachine; this.orderMerger = orderMerger; this.paymentService = paymentService; this.paymentMethodService = paymentMethodService; this.fulfillmentService = fulfillmentService; this.listQueryBuilder = listQueryBuilder; this.refundStateMachine = refundStateMachine; this.historyService = historyService; this.promotionService = promotionService; this.eventBus = eventBus; this.channelService = channelService; this.orderModifier = orderModifier; this.customFieldRelationService = customFieldRelationService; this.requestCache = requestCache; this.translator = translator; this.stockLevelService = stockLevelService; } /** * @description * Returns an array of all the configured states and transitions of the order process. This is * based on the default order process plus all configured {@link OrderProcess} objects * defined in the {@link OrderOptions} `process` array. */ getOrderProcessStates() { return Object.entries(this.orderStateMachine.config.transitions).map(([name, { to }]) => ({ name, to, })); } findAll(ctx, options, relations) { return this.listQueryBuilder .build(order_entity_1.Order, options, { ctx, relations: relations !== null && relations !== void 0 ? relations : [ 'lines', 'customer', 'lines.productVariant', 'channels', 'shippingLines', 'payments', ], channelId: ctx.channelId, customPropertyMap: { customerLastName: 'customer.lastName', transactionId: 'payments.transactionId', }, }) .getManyAndCount() .then(([items, totalItems]) => { return { items, totalItems, }; }); } async findOne(ctx, orderId, relations) { const qb = this.connection.getRepository(ctx, order_entity_1.Order).createQueryBuilder('order'); const effectiveRelations = relations !== null && relations !== void 0 ? relations : [ 'channels', 'customer', 'customer.user', 'lines', 'lines.productVariant', 'lines.productVariant.taxCategory', 'lines.productVariant.productVariantPrices', 'lines.productVariant.translations', 'lines.featuredAsset', 'lines.taxCategory', 'shippingLines', 'surcharges', ]; // Any caller requesting `lines.productVariant` (directly or via a sub-relation // path) implies the variant will be loaded into the split lines query and // translated below. TypeORM doesn't honour transitive `eager: true` relations // (like `ProductVariant.translations`) when an explicit `relations` array is // passed to `setFindOptions`, so we inject `taxCategory` and `translations` // ourselves to keep `applyChannelPriceAndTax` + `translator.translate` correct. const wantsLineVariant = effectiveRelations.some(r => r === 'lines.productVariant' || r.startsWith('lines.productVariant.')); if (relations && wantsLineVariant) { if (!effectiveRelations.includes('lines.productVariant.taxCategory')) { effectiveRelations.push('lines.productVariant.taxCategory'); } if (!effectiveRelations.includes('lines.productVariant.translations')) { effectiveRelations.push('lines.productVariant.translations'); } } // Split relations into two groups for different loading strategies: // Main order relations - loaded with 'query' strategy for performance const orderRelations = effectiveRelations.filter(r => !r.startsWith('lines')); // Lines relations - loaded with 'join' strategy to enable multi-column sorting const lineRelations = effectiveRelations .filter(r => r.startsWith('lines.')) .map(r => r.replace('lines.', '')); qb.setFindOptions({ relations: orderRelations, relationLoadStrategy: 'query', }) .leftJoin('order.channels', 'channel') .where('order.id = :orderId', { orderId }) .andWhere('channel.id = :channelId', { channelId: ctx.channelId }); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion FindOptionsUtils_1.FindOptionsUtils.joinEagerRelations(qb, qb.alias, qb.expressionMap.mainAlias.metadata); const order = await qb.getOne(); if (order) { const hasLinesRelations = effectiveRelations.some(r => r.startsWith('lines')); if (hasLinesRelations) { const linesQb = this.connection.getRepository(ctx, order_line_entity_1.OrderLine).createQueryBuilder('line'); linesQb .setFindOptions({ relations: lineRelations, }) .where('line.orderId = :orderId', { orderId }) .addOrderBy('line.createdAt', 'ASC') .addOrderBy('line.productVariantId', 'ASC'); const lines = await linesQb.getMany(); order.lines = lines; } if (wantsLineVariant) { for (const line of order.lines) { line.productVariant = this.translator.translate(await this.productVariantService.applyChannelPriceAndTax(line.productVariant, ctx, order), ctx); } } return order; } } async findOneByCode(ctx, orderCode, relations) { const order = await this.connection.getRepository(ctx, order_entity_1.Order).findOne({ relations: ['customer'], where: { code: orderCode, }, }); return order ? this.findOne(ctx, order.id, relations) : undefined; } async findOneByOrderLineId(ctx, orderLineId, relations) { const order = await this.connection .getRepository(ctx, order_entity_1.Order) .createQueryBuilder('order') .innerJoin('order.lines', 'line', 'line.id = :orderLineId', { orderLineId }) .getOne(); return order ? this.findOne(ctx, order.id, relations) : undefined; } async findByCustomerId(ctx, customerId, options, relations) { return this.listQueryBuilder .build(order_entity_1.Order, options, { relations: relations !== null && relations !== void 0 ? relations : ['lines', 'customer', 'channels', 'shippingLines'], channelId: ctx.channelId, ctx, }) .andWhere('order.state != :draftState', { draftState: 'Draft' }) .andWhere('order.customer.id = :customerId', { customerId }) .getManyAndCount() .then(([items, totalItems]) => { return { items, totalItems, }; }); } /** * @description * Returns all {@link Payment} entities associated with the Order. */ getOrderPayments(ctx, orderId) { return this.connection.getRepository(ctx, payment_entity_1.Payment).find({ relations: ['refunds'], where: { order: { id: orderId }, }, }); } /** * @description * Returns an array of any {@link OrderModification} entities associated with the Order. */ getOrderModifications(ctx, orderId) { return this.connection.getRepository(ctx, order_modification_entity_1.OrderModification).find({ where: { order: { id: orderId }, }, relations: ['lines', 'payment', 'refund', 'surcharges'], }); } /** * @description * Returns any {@link Refund}s associated with a {@link Payment}. */ getPaymentRefunds(ctx, paymentId) { return this.connection.getRepository(ctx, refund_entity_1.Refund).find({ where: { paymentId, }, }); } getSellerOrders(ctx, order) { return this.connection.getRepository(ctx, order_entity_1.Order).find({ where: { aggregateOrderId: order.id, }, relations: ['channels'], }); } async getAggregateOrder(ctx, order) { return order.aggregateOrderId == null ? undefined : this.connection .getRepository(ctx, order_entity_1.Order) .findOne({ where: { id: order.aggregateOrderId }, relations: ['channels', 'lines'] }) .then(result => result !== null && result !== void 0 ? result : undefined); } getOrderChannels(ctx, order) { return this.connection .getRepository(ctx, order_entity_1.Order) .createQueryBuilder('order') .relation('channels') .of(order) .loadMany(); } /** * @description * Returns any Order associated with the specified User's Customer account * that is still in the `active` state. */ async getActiveOrderForUser(ctx, userId) { const customer = await this.customerService.findOneByUserId(ctx, userId); if (customer) { const activeOrder = await this.connection .getRepository(ctx, order_entity_1.Order) .createQueryBuilder('order') .innerJoinAndSelect('order.channels', 'channel', 'channel.id = :channelId', { channelId: ctx.channelId, }) .leftJoinAndSelect('order.customer', 'customer') .leftJoinAndSelect('order.shippingLines', 'shippingLines') .where('order.active = :active', { active: true }) .andWhere('order.customer.id = :customerId', { customerId: customer.id }) .orderBy('order.createdAt', 'DESC') .getOne(); if (activeOrder) { return this.findOne(ctx, activeOrder.id); } } } /** * @description * Creates a new, empty Order. If a `userId` is passed, the Order will get associated with that * User's Customer account. */ async create(ctx, userId) { const newOrder = await this.createEmptyOrderEntity(ctx); if (userId) { const customer = await this.customerService.findOneByUserId(ctx, userId); if (customer) { newOrder.customer = customer; } } await this.channelService.assignToCurrentChannel(newOrder, ctx); const order = await this.connection.getRepository(ctx, order_entity_1.Order).save(newOrder); await this.eventBus.publish(new order_event_1.OrderEvent(ctx, order, 'created')); const transitionResult = await this.transitionToState(ctx, order.id, 'AddingItems'); if ((0, error_result_1.isGraphQlErrorResult)(transitionResult)) { // this should never occur, so we will throw rather than return throw transitionResult; } return transitionResult; } async createDraft(ctx) { const newOrder = await this.createEmptyOrderEntity(ctx); newOrder.active = false; await this.channelService.assignToCurrentChannel(newOrder, ctx); const order = await this.connection.getRepository(ctx, order_entity_1.Order).save(newOrder); await this.eventBus.publish(new order_event_1.OrderEvent(ctx, order, 'created')); const transitionResult = await this.transitionToState(ctx, order.id, 'Draft'); if ((0, error_result_1.isGraphQlErrorResult)(transitionResult)) { // this should never occur, so we will throw rather than return throw transitionResult; } return transitionResult; } async createEmptyOrderEntity(ctx) { return new order_entity_1.Order({ type: generated_types_1.OrderType.Regular, code: await this.configService.orderOptions.orderCodeStrategy.generate(ctx), state: this.orderStateMachine.getInitialState(), lines: [], surcharges: [], couponCodes: [], modifications: [], shippingAddress: {}, billingAddress: {}, subTotal: 0, subTotalWithTax: 0, currencyCode: ctx.currencyCode, }); } /** * @description * Updates the custom fields of an Order. */ async updateCustomFields(ctx, orderId, customFields) { let order = await this.getOrderOrThrow(ctx, orderId); order = (0, patch_entity_1.patchEntity)(order, { customFields }); const updatedOrder = await this.connection.getRepository(ctx, order_entity_1.Order).save(order); await this.customFieldRelationService.updateRelations(ctx, order_entity_1.Order, { customFields }, updatedOrder); await this.eventBus.publish(new order_event_1.OrderEvent(ctx, updatedOrder, 'updated', { customFields })); return updatedOrder; } /** * @description * Updates the Customer which is assigned to a given Order. The target Customer must be assigned to the same * Channels as the Order, otherwise an error will be thrown. * * @since 2.2.0 */ async updateOrderCustomer(ctx, { customerId, orderId, note }) { const order = await this.getOrderOrThrow(ctx, orderId, ['channels', 'customer']); const currentCustomer = order.customer; if ((currentCustomer === null || currentCustomer === void 0 ? void 0 : currentCustomer.id) === customerId) { // No change in customer, so just return the order as-is return order; } const targetCustomer = await this.customerService.findOne(ctx, customerId, ['channels']); if (!targetCustomer) { throw new errors_1.EntityNotFoundError('Customer', customerId); } // ensure the customer is assigned to the same channels as the order const channelIds = order.channels.map(c => c.id); const customerChannelIds = targetCustomer.channels.map(c => c.id); const missingChannelIds = channelIds.filter(id => !customerChannelIds.includes(id)); if (missingChannelIds.length) { throw new errors_1.UserInputError(`error.target-customer-not-assigned-to-order-channels`, { channelIds: missingChannelIds.join(', '), }); } const updatedOrder = await this.addCustomerToOrder(ctx, order.id, targetCustomer); await this.eventBus.publish(new order_event_1.OrderEvent(ctx, updatedOrder, 'updated', targetCustomer)); await this.historyService.createHistoryEntryForOrder({ ctx, orderId, type: generated_types_1.HistoryEntryType.ORDER_CUSTOMER_UPDATED, data: { previousCustomerId: currentCustomer === null || currentCustomer === void 0 ? void 0 : currentCustomer.id, previousCustomerName: currentCustomer && `${currentCustomer.firstName} ${currentCustomer.lastName}`, newCustomerId: targetCustomer.id, newCustomerName: `${targetCustomer.firstName} ${targetCustomer.lastName}`, note, }, }); return updatedOrder; } /** * @description * Updates the currency code of an Order. This will recalculate all prices * in the new currency using `applyPriceAdjustments`. * * @since 3.3.0 */ async updateOrderCurrency(ctx, orderId, currencyCode, relations) { const order = await this.getOrderOrThrow(ctx, orderId); const validationError = this.assertAddingItemsState(order); if (validationError) { return validationError; } if (order.currencyCode === currencyCode) { return order; } const channel = await this.channelService.getChannelFromToken(ctx.channel.token); if (!channel.availableCurrencyCodes.includes(currencyCode)) { throw new errors_1.UserInputError('error.currency-not-available', { currencyCode }); } const previousCurrencyCode = order.currencyCode; const newCurrencyCtx = ctx.copy(); newCurrencyCtx._currencyCode = currencyCode; order.currencyCode = currencyCode; await this.historyService.createHistoryEntryForOrder({ ctx, orderId: order.id, type: generated_types_1.HistoryEntryType.ORDER_CURRENCY_UPDATED, data: { previousCurrency: previousCurrencyCode, newCurrency: currencyCode, }, }); const updatedOrder = await this.applyPriceAdjustments(newCurrencyCtx, order, order.lines, relations); await this.eventBus.publish(new order_event_1.OrderEvent(ctx, updatedOrder, 'updated')); return updatedOrder; } /** * @description * Adds an item to the Order, either creating a new OrderLine or * incrementing an existing one. * * If you need to add multiple items to an Order, use `addItemsToOrder()` instead. */ async addItemToOrder(ctx, orderId, productVariantId, quantity, customFields, relations) { const result = await this.addItemsToOrder(ctx, orderId, [{ productVariantId, quantity, customFields }], relations); if (result.errorResults.length) { return result.errorResults[0]; } else { return result.order; } } /** * @description * Adds multiple items to an Order. This method is more efficient than calling `addItemToOrder` * multiple times, as it only needs to fetch the entire Order once, and only performs * price adjustments once at the end. * * Since this method can return multiple error results, it is recommended to check the `errorResults` * array to determine if any errors occurred. * * @since 3.1.0 */ async addItemsToOrder(ctx, orderId, items, relations) { const order = await this.getOrderOrThrow(ctx, orderId); const errorResults = []; const updatedOrderLines = []; addItem: for (const item of items) { const { productVariantId, quantity, customFields } = item; const existingOrderLine = await this.orderModifier.getExistingOrderLine(ctx, order, productVariantId, customFields); const validationError = this.assertQuantityIsPositive(quantity) || this.assertAddingItemsState(order) || this.assertNotOverOrderItemsLimit(order, quantity) || this.assertNotOverOrderLineItemsLimit(existingOrderLine, quantity); if (validationError) { errorResults.push(validationError); continue; } const variant = await this.connection.getEntityOrThrow(ctx, product_variant_entity_1.ProductVariant, productVariantId, { relations: ['product'], where: { enabled: true, deletedAt: (0, typeorm_1.IsNull)(), }, loadEagerRelations: false, }); if (variant.product.enabled === false) { throw new errors_1.EntityNotFoundError('ProductVariant', productVariantId); } const existingQuantityInOtherLines = (0, shared_utils_1.summate)(order.lines.filter(l => (0, utils_1.idsAreEqual)(l.productVariantId, productVariantId) && !(0, utils_1.idsAreEqual)(l.id, existingOrderLine === null || existingOrderLine === void 0 ? void 0 : existingOrderLine.id)), 'quantity'); const correctedQuantity = await this.orderModifier.constrainQuantityToSaleable(ctx, variant, quantity, existingOrderLine === null || existingOrderLine === void 0 ? void 0 : existingOrderLine.quantity, existingQuantityInOtherLines); if (correctedQuantity === 0) { errorResults.push(new generated_graphql_shop_errors_1.InsufficientStockError({ order, quantityAvailable: correctedQuantity })); continue; } const { orderInterceptors } = this.configService.orderOptions; for (const interceptor of orderInterceptors) { if (interceptor.willAddItemToOrder) { const error = await interceptor.willAddItemToOrder(ctx, order, { productVariant: variant, quantity: correctedQuantity, customFields, }); if (error) { errorResults.push(new generated_graphql_shop_errors_1.OrderInterceptorError({ interceptorError: error })); continue addItem; } } } const orderLine = await this.orderModifier.getOrCreateOrderLine(ctx, order, productVariantId, customFields); if (correctedQuantity < quantity) { const newQuantity = (existingOrderLine ? existingOrderLine === null || existingOrderLine === void 0 ? void 0 : existingOrderLine.quantity : 0) + correctedQuantity; await this.orderModifier.updateOrderLineQuantity(ctx, orderLine, newQuantity, order); } else { await this.orderModifier.updateOrderLineQuantity(ctx, orderLine, correctedQuantity, order); } updatedOrderLines.push(orderLine); const quantityWasAdjustedDown = correctedQuantity < quantity; if (quantityWasAdjustedDown) { errorResults.push(new generated_graphql_shop_errors_1.InsufficientStockError({ quantityAvailable: correctedQuantity, order })); continue; } } const updatedOrder = await this.applyPriceAdjustments(ctx, order, updatedOrderLines, relations); // for any InsufficientStockError errors, we want to make sure we use the final updatedOrder // after having applied all price adjustments for (const [i, errorResult] of Object.entries(errorResults)) { if (errorResult.__typename === 'InsufficientStockError') { errorResults[+i] = new generated_graphql_shop_errors_1.InsufficientStockError({ quantityAvailable: errorResult.quantityAvailable, order: updatedOrder, }); } } return { order: updatedOrder, errorResults, }; } /** * @description * Adjusts the quantity and/or custom field values of an existing OrderLine. * * If you need to adjust multiple OrderLines, use `adjustOrderLines()` instead. */ async adjustOrderLine(ctx, orderId, orderLineId, quantity, customFields, relations) { const result = await this.adjustOrderLines(ctx, orderId, [{ orderLineId, quantity, customFields }], relations); if (result.errorResults.length) { return result.errorResults[0]; } else { return result.order; } } /** * @description * Adjusts the quantity and/or custom field values of existing OrderLines. * This method is more efficient than calling `adjustOrderLine` multiple times, as it only needs to fetch * the entire Order once, and only performs price adjustments once at the end. * Since this method can return multiple error results, it is recommended to check the `errorResults` * array to determine if any errors occurred. * * @since 3.1.0 */ async adjustOrderLines(ctx, orderId, lines, relations) { const order = await this.getOrderOrThrow(ctx, orderId); const errorResults = []; const updatedOrderLines = []; adjustLine: for (const line of lines) { const { orderLineId, quantity, customFields } = line; const orderLine = this.getOrderLineOrThrow(order, orderLineId); const validationError = this.assertAddingItemsState(order) || this.assertQuantityIsPositive(quantity) || this.assertNotOverOrderItemsLimit(order, quantity - orderLine.quantity) || this.assertNotOverOrderLineItemsLimit(orderLine, quantity - orderLine.quantity); if (validationError) { errorResults.push(validationError); continue; } const { orderInterceptors } = this.configService.orderOptions; for (const interceptor of orderInterceptors) { if (interceptor.willAdjustOrderLine) { const error = await interceptor.willAdjustOrderLine(ctx, order, { orderLine, quantity, customFields, }); if (error) { errorResults.push(new generated_graphql_shop_errors_1.OrderInterceptorError({ interceptorError: error })); continue adjustLine; } } } if (customFields != null) { // Merge custom fields instead of replacing them entirely // This preserves existing values while allowing updates and null-based unsetting const existingCustomFields = orderLine.customFields || {}; const mergedCustomFields = Object.assign({}, existingCustomFields); for (const [key, value] of Object.entries(customFields)) { if (value !== undefined) { // Update with the new value (including explicit null to unset) mergedCustomFields[key] = value; } // If value is undefined, preserve the existing value (don't set it) } orderLine.customFields = mergedCustomFields; await this.customFieldRelationService.updateRelations(ctx, order_line_entity_1.OrderLine, { customFields: mergedCustomFields }, orderLine); } const existingQuantityInOtherLines = (0, shared_utils_1.summate)(order.lines.filter(l => (0, utils_1.idsAreEqual)(l.productVariantId, orderLine.productVariantId) && !(0, utils_1.idsAreEqual)(l.id, orderLineId)), 'quantity'); const correctedQuantity = await this.orderModifier.constrainQuantityToSaleable(ctx, orderLine.productVariant, quantity, 0, existingQuantityInOtherLines); if (correctedQuantity === 0) { order.lines = order.lines.filter(l => !(0, utils_1.idsAreEqual)(l.id, orderLine.id)); const deletedOrderLine = new order_line_entity_1.OrderLine(orderLine); await this.connection.getRepository(ctx, order_line_entity_1.OrderLine).remove(orderLine); await this.eventBus.publish(new order_line_event_1.OrderLineEvent(ctx, order, deletedOrderLine, 'deleted')); } else { await this.orderModifier.updateOrderLineQuantity(ctx, orderLine, correctedQuantity, order); updatedOrderLines.push(orderLine); } const quantityWasAdjustedDown = correctedQuantity < quantity; if (quantityWasAdjustedDown) { errorResults.push(new generated_graphql_shop_errors_1.InsufficientStockError({ quantityAvailable: correctedQuantity, order, })); } } const updatedOrder = await this.applyPriceAdjustments(ctx, order, updatedOrderLines, relations); for (const [i, errorResult] of Object.entries(errorResults)) { if (errorResult.__typename === 'InsufficientStockError') { errorResults[+i] = new generated_graphql_shop_errors_1.InsufficientStockError({ quantityAvailable: errorResult.quantityAvailable, order: updatedOrder, }); } } return { order: updatedOrder, errorResults, }; } /** * @description * Removes the specified OrderLine from the Order. * * If you need to remove multiple OrderLines, use `removeItemsFromOrder()` instead. */ async removeItemFromOrder(ctx, orderId, orderLineId) { return this.removeItemsFromOrder(ctx, orderId, [orderLineId]); } /** * @description * Removes the specified OrderLines from the Order. * This method is more efficient than calling `removeItemFromOrder` multiple times, as it only needs to fetch * the entire Order once, and only performs price adjustments once at the end. * * @since 3.1.0 */ async removeItemsFromOrder(ctx, orderId, orderLineIds) { const order = await this.getOrderOrThrow(ctx, orderId); const validationError = this.assertAddingItemsState(order); if (validationError) { return validationError; } const orderLinesToDelete = []; for (const orderLineId of orderLineIds) { const orderLine = this.getOrderLineOrThrow(order, orderLineId); const { orderInterceptors } = this.configService.orderOptions; for (const interceptor of orderInterceptors) { if (interceptor.willRemoveItemFromOrder) { const error = await interceptor.willRemoveItemFromOrder(ctx, order, orderLine); if (error) { return new generated_graphql_shop_errors_1.OrderInterceptorError({ interceptorError: error }); } } } orderLinesToDelete.push(orderLine); } order.lines = order.lines.filter(line => !orderLineIds.find(olId => (0, utils_1.idsAreEqual)(line.id, olId))); // Persist the orderLine removal before applying price adjustments // so that any hydration of the Order entity during the course of the // `applyPriceAdjustments()` (e.g. in a ShippingEligibilityChecker etc) // will not re-add the OrderLine. await this.connection.getRepository(ctx, order_entity_1.Order).save(order, { reload: false }); const updatedOrder = await this.applyPriceAdjustments(ctx, order); for (const orderLine of orderLinesToDelete) { const deletedOrderLine = new order_line_entity_1.OrderLine(orderLine); await this.connection.getRepository(ctx, order_line_entity_1.OrderLine).remove(orderLine); await this.eventBus.publish(new order_line_event_1.OrderLineEvent(ctx, order, deletedOrderLine, 'deleted')); } return updatedOrder; } /** * @description * Removes all OrderLines from the Order. */ async removeAllItemsFromOrder(ctx, orderId) { const order = await this.getOrderOrThrow(ctx, orderId); const validationError = this.assertAddingItemsState(order); if (validationError) { return validationError; } const { orderInterceptors } = this.configService.orderOptions; for (const orderLine of order.lines) { for (const interceptor of orderInterceptors) { if (interceptor.willRemoveItemFromOrder) { const error = await interceptor.willRemoveItemFromOrder(ctx, order, orderLine); if (error) { return new generated_graphql_shop_errors_1.OrderInterceptorError({ interceptorError: error }); } } } } await this.connection.getRepository(ctx, order_line_entity_1.OrderLine).remove(order.lines); order.lines = []; const updatedOrder = await this.applyPriceAdjustments(ctx, order); return updatedOrder; } /** * @description * Adds a {@link Surcharge} to the Order. */ async addSurchargeToOrder(ctx, orderId, surchargeInput) { const order = await this.getOrderOrThrow(ctx, orderId); const surcharge = await this.connection.getRepository(ctx, surcharge_entity_1.Surcharge).save(new surcharge_entity_1.Surcharge(Object.assign({ taxLines: [], sku: '', listPriceIncludesTax: ctx.channel.pricesIncludeTax, order }, surchargeInput))); order.surcharges.push(surcharge); const updatedOrder = await this.applyPriceAdjustments(ctx, order); return updatedOrder; } /** * @description * Removes a {@link Surcharge} from the Order. */ async removeSurchargeFromOrder(ctx, orderId, surchargeId) { const order = await this.getOrderOrThrow(ctx, orderId); const surcharge = await this.connection.getEntityOrThrow(ctx, surcharge_entity_1.Surcharge, surchargeId); if (order.surcharges.find(s => (0, utils_1.idsAreEqual)(s.id, surcharge.id))) { order.surcharges = order.surcharges.filter(s => !(0, utils_1.idsAreEqual)(s.id, surchargeId)); const updatedOrder = await this.applyPriceAdjustments(ctx, order); await this.connection.getRepository(ctx, surcharge_entity_1.Surcharge).remove(surcharge); return updatedOrder; } else { return order; } } /** * @description * Applies a coupon code to the Order, which should be a valid coupon code as specified in the configuration * of an active {@link Promotion}. */ async applyCouponCode(ctx, orderId, couponCode) { const order = await this.getOrderOrThrow(ctx, orderId); if (order.couponCodes.some(cc => (0, coupon_codes_match_1.couponCodesMatch)(cc, couponCode))) { return order; } const validationResult = await this.promotionService.validateCouponCode(ctx, couponCode, order.customer && order.customer.id); if ((0, error_result_1.isGraphQlErrorResult)(validationResult)) { return validationResult; } // Store the canonical coupon code from the promotion rather than the // user-typed casing, so that subsequent lookups are consistent. const canonicalCode = validationResult.couponCode; order.couponCodes.push(canonicalCode); await this.historyService.createHistoryEntryForOrder({ ctx, orderId: order.id, type: generated_types_1.HistoryEntryType.ORDER_COUPON_APPLIED, data: { couponCode: canonicalCode, promotionId: validationResult.id }, }); await this.eventBus.publish(new coupon_code_event_1.CouponCodeEvent(ctx, canonicalCode, orderId, 'assigned')); return this.applyPriceAdjustments(ctx, order); } /** * @description * Removes a coupon code from the Order. */ async removeCouponCode(ctx, orderId, couponCode) { const order = await this.getOrderOrThrow(ctx, orderId); const matchedCode = order.couponCodes.find(cc => (0, coupon_codes_match_1.couponCodesMatch)(cc, couponCode)); if (matchedCode) { order.couponCodes = order.couponCodes.filter(cc => !(0, coupon_codes_match_1.couponCodesMatch)(cc, matchedCode)); await this.historyService.createHistoryEntryForOrder({ ctx, orderId: order.id, type: generated_types_1.HistoryEntryType.ORDER_COUPON_REMOVED, data: { couponCode: matchedCode }, }); await this.eventBus.publish(new coupon_code_event_1.CouponCodeEvent(ctx, matchedCode, orderId, 'removed')); return this.applyPriceAdjustments(ctx, order); } else { return order; } } /** * @description * Returns all {@link Promotion}s associated with an Order. */ async getOrderPromotions(ctx, orderId) { const order = await this.connection.getEntityOrThrow(ctx, order_entity_1.Order, orderId, { channelId: ctx.channelId, relations: ['promotions'], }); return order.promotions.map(p => this.translator.translate(p, ctx)) || []; } /** * @description * Returns the next possible states that the Order may transition to. */ getNextOrderStates(order) { return this.orderStateMachine.getNextStates(order); } /** * @description * Sets the shipping address for the Order. */ async setShippingAddress(ctx, orderId, input) { const order = await this.getOrderOrThrow(ctx, orderId); const country = await this.countryService.findOneByCode(ctx, input.countryCode); const shippingAddress = Object.assign(Object.assign({}, input), { countryCode: input.countryCode, country: country.name }); await this.connection .getRepository(ctx, order_entity_1.Order) .createQueryBuilder('order') .update(order_entity_1.Order) .set({ shippingAddress }) .where('id = :id', { id: order.id }) .execute(); order.shippingAddress = shippingAddress; // Since a changed ShippingAddress could alter the activeTaxZone, // we will remove any cached activeTaxZone, so it can be re-calculated // as needed. this.requestCache.set(ctx, constants_1.CacheKey.ActiveTaxZone(ctx.channelId), undefined); this.requestCache.set(ctx, constants_1.CacheKey.ActiveTaxZone_PPA(ctx.channelId), undefined); return this.applyPriceAdjustments(ctx, order, order.lines); } /** * @description * Sets the billing address for the Order. */ async setBillingAddress(ctx, orderId, input) { const order = await this.getOrderOrThrow(ctx, orderId); const country = await this.countryService.findOneByCode(ctx, input.countryCode); const billingAddress = Object.assign(Object.assign({}, input), { countryCode: input.countryCode, country: country.name }); await this.connection .getRepository(ctx, order_entity_1.Order) .createQueryBuilder('order') .update(order_entity_1.Order) .set({ billingAddress }) .where('id = :id', { id: order.id }) .execute(); order.billingAddress = billingAddress; // Since a changed BillingAddress could alter the activeTaxZone, // we will remove any cached activeTaxZone, so it can be re-calculated // as needed. this.requestCache.set(ctx, constants_1.CacheKey.ActiveTaxZone(ctx.channelId), undefined); this.requestCache.set(ctx, constants_1.CacheKey.ActiveTaxZone_PPA(ctx.channelId), undefined); return this.applyPriceAdjustments(ctx, order, order.lines); } /** * @description * Unsets the shipping address for the Order. * * @since 3.1.0 */ async unsetShippingAddress(ctx, orderId) { const order = await this.getOrderOrThrow(ctx, orderId); await this.connection .getRepository(ctx, order_entity_1.Order) .createQueryBuilder('order') .update(order_entity_1.Order) .set({ shippingAddress: {} }) .where('id = :id', { id: order.id }) .execute(); order.shippingAddress = {}; // Since a changed ShippingAddress could alter the activeTaxZone, // we will remove any cached activeTaxZone, so it can be re-calculated // as needed. this.requestCache.set(ctx, constants_1.CacheKey.ActiveTaxZone(ctx.channelId), undefined); this.requestCache.set(ctx, constants_1.CacheKey.ActiveTaxZone_PPA(ctx.channelId), undefined); return this.applyPriceAdjustments(ctx, order, order.lines); } /** * @description * Unsets the billing address for the Order. * * @since 3.1.0 */ async unsetBillingAddress(ctx, orderId) { const order = await this.getOrderOrThrow(ctx, orderId); await this.connection .getRepository(ctx, order_entity_1.Order) .createQueryBuilder('order') .update(order_entity_1.Order) .set({ billingAddress: {} }) .where('id = :id', { id: order.id }) .execute(); order.billingAddress = {}; // Since a changed BillingAddress could alter the activeTaxZone, // we will remove any cached activeTaxZone, so it can be re-calculated // as needed. this.requestCache.set(ctx, constants_1.CacheKey.ActiveTaxZone(ctx.channelId), undefined); this.requestCache.set(ctx, constants_1.CacheKey.ActiveTaxZone_PPA(ctx.channelId), undefined); return this.applyPriceAdjustments(ctx, order, order.lines); } /** * @description * Returns an array of quotes stating which {@link ShippingMethod}s may be applied to this Order. * This is determined by the configured {@link ShippingEligibilityChecker} of each ShippingMethod. * * The quote also includes a price for each method, as determined by the configured * {@link ShippingCalculator} of each eligible ShippingMethod. */ async getEligibleShippingMethods(ctx, orderId) { const order = await this.getOrderOrThrow(ctx, orderId); const eligibleMethods = await this.shippingCalculator.getEligibleShippingMethods(ctx, order); return eligibleMethods.map(eligible => { const { price, taxRate, priceIncludesTax, metadata } = eligible.result; return { id: eligible.method.id, price: priceIncludesTax ? (0, tax_utils_1.netPriceOf)(price, taxRate) : price, priceWithTax: priceIncludesTax ? price : (0, tax_utils_1.grossPriceOf)(price, taxRate), description: eligible.method.description, name: eligible.method.name, code: eligible.method.code, metadata, customFields: eligible.method.customFields, }; }); } /** * @description * Returns an array of quotes stating which {@link PaymentMethod}s may be used on this Order. */ async getEligiblePaymentMethods(ctx, orderId) { const order = await this.getOrderOrThrow(ctx, orderId); return this.paymentMethodService.getEligiblePaymentMethods(ctx, order); } /** * @description * Sets the ShippingMethod to be used on this Order. */ async setShippingMethod(ctx, orderId, shippingMethodIds) { const order = await this.getOrderOrThrow(ctx, orderId); const validationError = this.assertAddingItemsState(order); if (validationError) { return validationError; } const result = await this.orderModifier.setShippingMethods(ctx, order, shippingMethodIds); if ((0, error_result_1.isGraphQlErrorResult)(result)) { return result; } const updatedOrder = await this.getOrderOrThrow(ctx, orderId); await this.applyPriceAdjustments(ctx, updatedOrder); return this.connection.getRepository(ctx, order_entity_1.Order).save(updatedOrder); } /** * @description * Transitions the Order to the given state. */ async transitionToState(ctx, orderId, state) { // Wrapped in withTransaction so that the in-memory state mutation, the // first save, the onTransitionEnd hooks (which themselves perform DB // writes — stock allocations, history entries, OrderPlacedStra