@vendure/core
Version:
A modern, headless ecommerce framework
425 lines • 23 kB
JavaScript
"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