UNPKG

@vendure/core

Version:

A modern, headless ecommerce framework

425 lines 23 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.PromotionService = void 0; const common_1 = require("@nestjs/common"); const generated_types_1 = require("@vendure/common/lib/generated-types"); const unique_1 = require("@vendure/common/lib/unique"); const typeorm_1 = require("typeorm"); 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 adjustment_source_1 = require("../../common/types/adjustment-source"); const utils_1 = require("../../common/utils"); const config_service_1 = require("../../config/config.service"); const transactional_connection_1 = require("../../connection/transactional-connection"); const order_entity_1 = require("../../entity/order/order.entity"); const promotion_translation_entity_1 = require("../../entity/promotion/promotion-translation.entity"); const promotion_entity_1 = require("../../entity/promotion/promotion.entity"); const event_bus_1 = require("../../event-bus"); const promotion_event_1 = require("../../event-bus/events/promotion-event"); const config_arg_service_1 = require("../helpers/config-arg/config-arg.service"); 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 translatable_saver_1 = require("../helpers/translatable-saver/translatable-saver"); const translator_service_1 = require("../helpers/translator/translator.service"); const channel_service_1 = require("./channel.service"); /** * @description * Contains methods relating to {@link Promotion} entities. * * @docsCategory services */ let PromotionService = class PromotionService { constructor(connection, configService, channelService, listQueryBuilder, configArgService, customFieldRelationService, eventBus, translatableSaver, translator) { this.connection = connection; this.configService = configService; this.channelService = channelService; this.listQueryBuilder = listQueryBuilder; this.configArgService = configArgService; this.customFieldRelationService = customFieldRelationService; this.eventBus = eventBus; this.translatableSaver = translatableSaver; this.translator = translator; this.availableConditions = []; this.availableActions = []; this.availableConditions = this.configService.promotionOptions.promotionConditions || []; this.availableActions = this.configService.promotionOptions.promotionActions || []; } findAll(ctx, options, relations = []) { return this.listQueryBuilder .build(promotion_entity_1.Promotion, options, { where: { deletedAt: (0, typeorm_1.IsNull)() }, channelId: ctx.channelId, relations, ctx, }) .getManyAndCount() .then(([promotions, totalItems]) => { const items = promotions.map(promotion => this.translator.translate(promotion, ctx)); return { items, totalItems, }; }); } async findOne(ctx, adjustmentSourceId, relations = []) { return this.connection .findOneInChannel(ctx, promotion_entity_1.Promotion, adjustmentSourceId, ctx.channelId, { where: { deletedAt: (0, typeorm_1.IsNull)() }, relations, }) .then(promotion => { var _a; return (_a = (promotion && this.translator.translate(promotion, ctx))) !== null && _a !== void 0 ? _a : undefined; }); } getPromotionConditions(ctx) { return this.availableConditions.map(x => x.toGraphQlType(ctx)); } getPromotionActions(ctx) { return this.availableActions.map(x => x.toGraphQlType(ctx)); } async createPromotion(ctx, input) { const conditions = input.conditions.map(c => this.configArgService.parseInput('PromotionCondition', c)); const actions = input.actions.map(a => this.configArgService.parseInput('PromotionAction', a)); this.validateRequiredConditions(conditions, actions); if (conditions.length === 0 && !input.couponCode) { return new generated_graphql_admin_errors_1.MissingConditionsError(); } const newPromotion = await this.translatableSaver.create({ ctx, input, entityType: promotion_entity_1.Promotion, translationType: promotion_translation_entity_1.PromotionTranslation, beforeSave: async (p) => { p.priorityScore = this.calculatePriorityScore(input); p.conditions = conditions; p.actions = actions; await this.channelService.assignToCurrentChannel(p, ctx); }, }); const promotionWithRelations = await this.customFieldRelationService.updateRelations(ctx, promotion_entity_1.Promotion, input, newPromotion); await this.eventBus.publish(new promotion_event_1.PromotionEvent(ctx, promotionWithRelations, 'created', input)); return (0, utils_1.assertFound)(this.findOne(ctx, newPromotion.id)); } async updatePromotion(ctx, input) { const promotion = await this.connection.getEntityOrThrow(ctx, promotion_entity_1.Promotion, input.id, { channelId: ctx.channelId, }); const hasConditions = input.conditions ? input.conditions.length > 0 : promotion.conditions.length > 0; const hasCouponCode = input.couponCode != null ? !!input.couponCode : !!promotion.couponCode; if (!hasConditions && !hasCouponCode) { return new generated_graphql_admin_errors_1.MissingConditionsError(); } const updatedPromotion = await this.translatableSaver.update({ ctx, input, entityType: promotion_entity_1.Promotion, translationType: promotion_translation_entity_1.PromotionTranslation, beforeSave: async (p) => { p.priorityScore = this.calculatePriorityScore(input); if (input.conditions) { p.conditions = input.conditions.map(c => this.configArgService.parseInput('PromotionCondition', c)); } if (input.actions) { p.actions = input.actions.map(a => this.configArgService.parseInput('PromotionAction', a)); } }, }); await this.customFieldRelationService.updateRelations(ctx, promotion_entity_1.Promotion, input, updatedPromotion); await this.eventBus.publish(new promotion_event_1.PromotionEvent(ctx, updatedPromotion, 'updated', input)); return (0, utils_1.assertFound)(this.findOne(ctx, updatedPromotion.id)); } async softDeletePromotion(ctx, promotionId) { const promotion = await this.connection.getEntityOrThrow(ctx, promotion_entity_1.Promotion, promotionId); await this.connection .getRepository(ctx, promotion_entity_1.Promotion) .update({ id: promotionId }, { deletedAt: new Date() }); await this.eventBus.publish(new promotion_event_1.PromotionEvent(ctx, promotion, 'deleted', promotionId)); return { result: generated_types_1.DeletionResult.DELETED, }; } async assignPromotionsToChannel(ctx, input) { const promotions = await this.connection.findByIdsInChannel(ctx, promotion_entity_1.Promotion, input.promotionIds, ctx.channelId, {}); for (const promotion of promotions) { await this.channelService.assignToChannels(ctx, promotion_entity_1.Promotion, promotion.id, [input.channelId]); } return promotions.map(p => this.translator.translate(p, ctx)); } async removePromotionsFromChannel(ctx, input) { const promotions = await this.connection.findByIdsInChannel(ctx, promotion_entity_1.Promotion, input.promotionIds, ctx.channelId, {}); for (const promotion of promotions) { await this.channelService.removeFromChannels(ctx, promotion_entity_1.Promotion, promotion.id, [input.channelId]); } return promotions.map(p => this.translator.translate(p, ctx)); } /** * @description * Checks the validity of a coupon code, by checking that it is associated with an existing, * enabled and non-expired Promotion. The comparison is case-insensitive, so e.g. "SUMMER20" * and "summer20" are treated as the same code. Additionally, if there is a usage limit on the * coupon code, this method will enforce that limit against the specified Customer. */ async validateCouponCode(ctx, couponCode, customerId, excludeOrderId) { const promotion = await this.connection.getRepository(ctx, promotion_entity_1.Promotion).findOne({ where: { // Use Raw() with LOWER() for a case-insensitive DB lookup, so that e.g. // "summer20" matches a promotion with couponCode "SUMMER20". LOWER() is // supported across all Vendure DB backends (MariaDB, PostgreSQL, SQLite). couponCode: (0, typeorm_1.Raw)(alias => `LOWER(${alias}) = LOWER(:couponCode)`, { couponCode }), enabled: true, deletedAt: (0, typeorm_1.IsNull)(), channels: { id: ctx.channelId }, }, relations: ['channels'], }); if (!promotion || !promotion.channels.find(c => (0, utils_1.idsAreEqual)(c.id, ctx.channelId))) { return new generated_graphql_shop_errors_1.CouponCodeInvalidError({ couponCode }); } if (promotion.endsAt && +promotion.endsAt < +new Date()) { return new generated_graphql_shop_errors_1.CouponCodeExpiredError({ couponCode }); } if (customerId && promotion.perCustomerUsageLimit != null) { const usageCount = await this.countPromotionUsagesForCustomer(ctx, promotion.id, customerId, excludeOrderId); if (promotion.perCustomerUsageLimit <= usageCount) { return new generated_graphql_shop_errors_1.CouponCodeLimitError({ couponCode, limit: promotion.perCustomerUsageLimit }); } } if (promotion.usageLimit !== null) { const usageCount = await this.countPromotionUsages(ctx, promotion.id, excludeOrderId); if (promotion.usageLimit <= usageCount) { return new generated_graphql_shop_errors_1.CouponCodeLimitError({ couponCode, limit: promotion.usageLimit }); } } return promotion; } getActivePromotionsInChannel(ctx) { return this.connection .getRepository(ctx, promotion_entity_1.Promotion) .createQueryBuilder('promotion') .leftJoin('promotion.channels', 'channel') .leftJoinAndSelect('promotion.translations', 'translation') .where('channel.id = :channelId', { channelId: ctx.channelId }) .andWhere('promotion.deletedAt IS NULL') .andWhere('promotion.enabled = :enabled', { enabled: true }) .orderBy('promotion.priorityScore', 'ASC') .getMany() .then(promotions => promotions.map(p => this.translator.translate(p, ctx))); } async getActivePromotionsOnOrder(ctx, orderId) { var _a; const order = await this.connection .getRepository(ctx, order_entity_1.Order) .createQueryBuilder('order') .leftJoinAndSelect('order.promotions', 'promotions') .where('order.id = :orderId', { orderId }) .getOne(); return (_a = order === null || order === void 0 ? void 0 : order.promotions) !== null && _a !== void 0 ? _a : []; } async runPromotionSideEffects(ctx, order, promotionsPre) { const promotionsPost = order.promotions; for (const activePre of promotionsPre) { if (!promotionsPost.find(p => (0, utils_1.idsAreEqual)(p.id, activePre.id))) { // activePre is no longer active, so call onDeactivate await activePre.deactivate(ctx, order); } } for (const activePost of promotionsPost) { if (!promotionsPre.find(p => (0, utils_1.idsAreEqual)(p.id, activePost.id))) { // activePost was not previously active, so call onActivate await activePost.activate(ctx, order); } } } /** * @description * Used internally to associate a Promotion with an Order, once an Order has been placed. * * @deprecated This method is no longer used and will be removed in v2.0 */ async addPromotionsToOrder(ctx, order) { const allPromotionIds = order.discounts.map(a => adjustment_source_1.AdjustmentSource.decodeSourceId(a.adjustmentSource).id); const promotionIds = (0, unique_1.unique)(allPromotionIds); const promotions = await this.connection .getRepository(ctx, promotion_entity_1.Promotion) .find({ where: { id: (0, typeorm_1.In)(promotionIds) } }); order.promotions = promotions; return this.connection.getRepository(ctx, order_entity_1.Order).save(order); } /** * @description * Returns a Set of Promotion IDs (as strings) that have exceeded their `usageLimit` or * `perCustomerUsageLimit`. Only checks promotions without a coupon code * (coupon-based promotions are validated separately in `validateCouponCode()`). */ async getExhaustedPromotionIds(ctx, promotions, customerId) { var _a, _b; const exhaustedIds = new Set(); const withUsageLimit = promotions.filter(p => !p.couponCode && p.usageLimit != null); if (withUsageLimit.length) { const usageCounts = await this.getUsageCountsBatch(ctx, withUsageLimit.map(p => p.id)); for (const promotion of withUsageLimit) { const count = (_a = usageCounts.get(promotion.id.toString())) !== null && _a !== void 0 ? _a : 0; if (promotion.usageLimit <= count) { exhaustedIds.add(promotion.id.toString()); } } } // perCustomerUsageLimit can only be checked if we know the customer. // For guest checkouts without a customer, we skip this check // (matching the existing behavior in validateCouponCode). // Skip promotions already exhausted by the global usageLimit check above. const withPerCustomerLimit = promotions.filter(p => !p.couponCode && p.perCustomerUsageLimit != null && !exhaustedIds.has(p.id.toString())); if (customerId && withPerCustomerLimit.length) { const perCustomerCounts = await this.getUsageCountsBatch(ctx, withPerCustomerLimit.map(p => p.id), customerId); for (const promotion of withPerCustomerLimit) { const count = (_b = perCustomerCounts.get(promotion.id.toString())) !== null && _b !== void 0 ? _b : 0; if (promotion.perCustomerUsageLimit <= count) { exhaustedIds.add(promotion.id.toString()); } } } return exhaustedIds; } /** * Returns a base query builder for counting promotion usages on placed orders. * Excludes cancelled orders, draft orders, active (un-placed) orders, and * seller-type orders. */ placedOrdersWithPromotionQb(ctx) { return (this.connection .getRepository(ctx, order_entity_1.Order) .createQueryBuilder('order') .innerJoin('order.promotions', 'promotion') .andWhere('order.state != :state', { state: 'Cancelled' }) // Draft orders also have active=false, so they must be excluded explicitly, // otherwise a draft would count its own promotions as a consumed usage and // toggle them on/off on each recalculation (#4753). .andWhere('order.state != :draftState', { draftState: 'Draft' }) .andWhere('order.active = :active', { active: false }) .andWhere('order.type != :type', { type: generated_types_1.OrderType.Seller })); } async getUsageCountsBatch(ctx, promotionIds, customerId) { if (!promotionIds.length) { return new Map(); } const qb = this.placedOrdersWithPromotionQb(ctx) .select('promotion.id', 'promotionId') .addSelect('COUNT(DISTINCT order.id)', 'usageCount') .andWhere('promotion.id IN (:...promotionIds)', { promotionIds }) .groupBy('promotion.id'); if (customerId) { qb.andWhere('order.customer = :customerId', { customerId }); } const results = await qb.getRawMany(); return new Map(results.map(r => [r.promotionId.toString(), Number(r.usageCount)])); } /** * Counts the number of times a promotion has been used by the given * customer, including orders currently in the `ArrangingPayment` state * (excluding the given order). Mirrors `countPromotionUsages` so that * `perCustomerUsageLimit` is enforced under the same TOCTOU-safe * semantics as `usageLimit`. */ async countPromotionUsagesForCustomer(ctx, promotionId, customerId, excludeOrderId) { const completedQb = this.placedOrdersWithPromotionQb(ctx) .andWhere('promotion.id = :promotionId', { promotionId }) .andWhere('order.customer = :customerId', { customerId }); const pendingPaymentQb = this.connection .getRepository(ctx, order_entity_1.Order) .createQueryBuilder('order') .innerJoin('order.promotions', 'promotion') .where('promotion.id = :promotionId', { promotionId }) .andWhere('order.state = :state', { state: 'ArrangingPayment' }) .andWhere('order.type != :type', { type: generated_types_1.OrderType.Seller }) .andWhere('order.customer = :customerId', { customerId }); if (excludeOrderId) { completedQb.andWhere('order.id != :excludeOrderId', { excludeOrderId }); pendingPaymentQb.andWhere('order.id != :excludeOrderId', { excludeOrderId }); } const [completedCount, pendingCount] = await Promise.all([ completedQb.getCount(), pendingPaymentQb.getCount(), ]); return completedCount + pendingCount; } /** * Counts the number of times a promotion has been used, including orders * currently in the ArrangingPayment state (excluding the given order). * The ArrangingPayment count prevents concurrent checkouts from bypassing * the usage limit via a TOCTOU race condition. * See https://github.com/vendurehq/vendure/pull/4660 */ async countPromotionUsages(ctx, promotionId, excludeOrderId) { const completedQb = this.placedOrdersWithPromotionQb(ctx).andWhere('promotion.id = :promotionId', { promotionId, }); const pendingPaymentQb = this.connection .getRepository(ctx, order_entity_1.Order) .createQueryBuilder('order') .innerJoin('order.promotions', 'promotion') .where('promotion.id = :promotionId', { promotionId }) .andWhere('order.state = :state', { state: 'ArrangingPayment' }) .andWhere('order.type != :type', { type: generated_types_1.OrderType.Seller }); if (excludeOrderId) { completedQb.andWhere('order.id != :excludeOrderId', { excludeOrderId }); pendingPaymentQb.andWhere('order.id != :excludeOrderId', { excludeOrderId }); } const [completedCount, pendingCount] = await Promise.all([ completedQb.getCount(), pendingPaymentQb.getCount(), ]); return completedCount + pendingCount; } calculatePriorityScore(input) { const conditions = input.conditions ? input.conditions.map(c => this.configArgService.getByCode('PromotionCondition', c.code)) : []; const actions = input.actions ? input.actions.map(c => this.configArgService.getByCode('PromotionAction', c.code)) : []; return [...conditions, ...actions].reduce((score, op) => score + op.priorityValue, 0); } validateRequiredConditions(conditions, actions) { const conditionCodes = conditions.reduce((codeMap, { code }) => (Object.assign(Object.assign({}, codeMap), { [code]: code })), {}); for (const { code: actionCode } of actions) { const actionDef = this.configArgService.getByCode('PromotionAction', actionCode); const actionDependencies = actionDef.conditions || []; if (!actionDependencies || actionDependencies.length === 0) { continue; } const missingConditions = actionDependencies.filter(condition => !conditionCodes[condition.code]); if (missingConditions.length) { throw new errors_1.UserInputError('error.conditions-required-for-action', { action: actionCode, conditions: missingConditions.map(c => c.code).join(', '), }); } } } }; exports.PromotionService = PromotionService; exports.PromotionService = PromotionService = __decorate([ (0, common_1.Injectable)(), (0, instrument_decorator_1.Instrument)(), __metadata("design:paramtypes", [transactional_connection_1.TransactionalConnection, config_service_1.ConfigService, channel_service_1.ChannelService, list_query_builder_1.ListQueryBuilder, config_arg_service_1.ConfigArgService, custom_field_relation_service_1.CustomFieldRelationService, event_bus_1.EventBus, translatable_saver_1.TranslatableSaver, translator_service_1.TranslatorService]) ], PromotionService); //# sourceMappingURL=promotion.service.js.map