import { Repository, DataSource, Between, In, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; import { BaseService } from '../../../shared/services/base.service'; import { ServiceResult, QueryOptions } from '../../../shared/types'; import { Promotion, PromotionStatus, PromotionType, DiscountApplication, } from '../entities/promotion.entity'; import { PromotionProduct, ProductRole } from '../entities/promotion-product.entity'; export interface PromotionProductInput { productId: string; productCode: string; productName: string; variantId?: string; role?: ProductRole; discountPercent?: number; discountAmount?: number; fixedPrice?: number; minQuantity?: number; maxQuantity?: number; bundleQuantity?: number; getQuantity?: number; } export interface CreatePromotionInput { code: string; name: string; description?: string; type: PromotionType; discountApplication?: DiscountApplication; discountPercent?: number; discountAmount?: number; maxDiscount?: number; buyQuantity?: number; getQuantity?: number; getDiscountPercent?: number; quantityTiers?: { minQuantity: number; discountPercent: number }[]; bundlePrice?: number; startDate: Date; endDate?: Date; validDays?: string[]; validHoursStart?: string; validHoursEnd?: string; maxUsesTotal?: number; maxUsesPerCustomer?: number; minOrderAmount?: number; minQuantity?: number; appliesToAllProducts?: boolean; includedCategories?: string[]; excludedCategories?: string[]; excludedProducts?: string[]; includedBranches?: string[]; customerLevels?: string[]; newCustomersOnly?: boolean; loyaltyMembersOnly?: boolean; stackable?: boolean; stackableWith?: string[]; priority?: number; displayName?: string; badgeText?: string; badgeColor?: string; imageUrl?: string; posEnabled?: boolean; ecommerceEnabled?: boolean; termsAndConditions?: string; products?: PromotionProductInput[]; } export interface PromotionQueryOptions extends QueryOptions { status?: PromotionStatus; type?: PromotionType; branchId?: string; startDate?: Date; endDate?: Date; activeOnly?: boolean; } export class PromotionService extends BaseService { constructor( repository: Repository, private readonly productRepository: Repository, private readonly dataSource: DataSource ) { super(repository); } /** * Create a new promotion with products */ async createPromotion( tenantId: string, input: CreatePromotionInput, userId: string ): Promise> { const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); try { // Check for duplicate code const existing = await queryRunner.manager.findOne(Promotion, { where: { tenantId, code: input.code.toUpperCase() }, }); if (existing) { return { success: false, error: { code: 'DUPLICATE_CODE', message: 'Promotion code already exists' }, }; } const promotion = queryRunner.manager.create(Promotion, { tenantId, code: input.code.toUpperCase(), name: input.name, description: input.description, type: input.type, status: PromotionStatus.DRAFT, discountApplication: input.discountApplication ?? DiscountApplication.LINE, discountPercent: input.discountPercent, discountAmount: input.discountAmount, maxDiscount: input.maxDiscount, buyQuantity: input.buyQuantity, getQuantity: input.getQuantity, getDiscountPercent: input.getDiscountPercent, quantityTiers: input.quantityTiers, bundlePrice: input.bundlePrice, startDate: input.startDate, endDate: input.endDate, validDays: input.validDays, validHoursStart: input.validHoursStart, validHoursEnd: input.validHoursEnd, maxUsesTotal: input.maxUsesTotal, maxUsesPerCustomer: input.maxUsesPerCustomer, minOrderAmount: input.minOrderAmount, minQuantity: input.minQuantity, appliesToAllProducts: input.appliesToAllProducts ?? false, includedCategories: input.includedCategories, excludedCategories: input.excludedCategories, excludedProducts: input.excludedProducts, includedBranches: input.includedBranches, customerLevels: input.customerLevels, newCustomersOnly: input.newCustomersOnly ?? false, loyaltyMembersOnly: input.loyaltyMembersOnly ?? false, stackable: input.stackable ?? false, stackableWith: input.stackableWith, priority: input.priority ?? 0, displayName: input.displayName, badgeText: input.badgeText, badgeColor: input.badgeColor ?? '#ff0000', imageUrl: input.imageUrl, posEnabled: input.posEnabled ?? true, ecommerceEnabled: input.ecommerceEnabled ?? true, termsAndConditions: input.termsAndConditions, createdBy: userId, }); const savedPromotion = await queryRunner.manager.save(promotion); // Create promotion products if (input.products && input.products.length > 0) { for (const productInput of input.products) { const promotionProduct = queryRunner.manager.create(PromotionProduct, { tenantId, promotionId: savedPromotion.id, productId: productInput.productId, productCode: productInput.productCode, productName: productInput.productName, variantId: productInput.variantId, role: productInput.role ?? ProductRole.BOTH, discountPercent: productInput.discountPercent, discountAmount: productInput.discountAmount, fixedPrice: productInput.fixedPrice, minQuantity: productInput.minQuantity ?? 1, maxQuantity: productInput.maxQuantity, bundleQuantity: productInput.bundleQuantity ?? 1, getQuantity: productInput.getQuantity, }); await queryRunner.manager.save(promotionProduct); } } await queryRunner.commitTransaction(); // Reload with products const result = await this.repository.findOne({ where: { id: savedPromotion.id, tenantId }, relations: ['products'], }); return { success: true, data: result! }; } catch (error) { await queryRunner.rollbackTransaction(); return { success: false, error: { code: 'CREATE_PROMOTION_ERROR', message: error instanceof Error ? error.message : 'Failed to create promotion', }, }; } finally { await queryRunner.release(); } } /** * Update promotion */ async updatePromotion( tenantId: string, id: string, input: Partial, userId: string ): Promise> { const promotion = await this.repository.findOne({ where: { id, tenantId }, }); if (!promotion) { return { success: false, error: { code: 'NOT_FOUND', message: 'Promotion not found' }, }; } // Don't allow editing active promotions (only some fields) if (promotion.status === PromotionStatus.ACTIVE) { const allowedFields = ['endDate', 'maxUsesTotal', 'priority', 'status']; const inputKeys = Object.keys(input); const hasDisallowedFields = inputKeys.some(k => !allowedFields.includes(k)); if (hasDisallowedFields) { return { success: false, error: { code: 'PROMOTION_ACTIVE', message: 'Cannot modify active promotion. Only endDate, maxUsesTotal, priority, and status can be changed.', }, }; } } // Update allowed fields const { products, ...updateData } = input; Object.assign(promotion, updateData, { updatedBy: userId }); const saved = await this.repository.save(promotion); return { success: true, data: saved }; } /** * Activate promotion */ async activatePromotion( tenantId: string, id: string, userId: string ): Promise> { const promotion = await this.repository.findOne({ where: { id, tenantId }, relations: ['products'], }); if (!promotion) { return { success: false, error: { code: 'NOT_FOUND', message: 'Promotion not found' }, }; } if (promotion.status !== PromotionStatus.DRAFT && promotion.status !== PromotionStatus.SCHEDULED) { return { success: false, error: { code: 'INVALID_STATUS', message: 'Can only activate draft or scheduled promotions' }, }; } // Validate promotion has required data if (!promotion.appliesToAllProducts && (!promotion.products || promotion.products.length === 0)) { if (!promotion.includedCategories || promotion.includedCategories.length === 0) { return { success: false, error: { code: 'NO_PRODUCTS', message: 'Promotion must have products or categories defined' }, }; } } const now = new Date(); if (promotion.startDate > now) { promotion.status = PromotionStatus.SCHEDULED; } else { promotion.status = PromotionStatus.ACTIVE; } promotion.updatedBy = userId; const saved = await this.repository.save(promotion); return { success: true, data: saved }; } /** * Pause promotion */ async pausePromotion( tenantId: string, id: string, userId: string ): Promise> { const promotion = await this.repository.findOne({ where: { id, tenantId }, }); if (!promotion) { return { success: false, error: { code: 'NOT_FOUND', message: 'Promotion not found' }, }; } if (promotion.status !== PromotionStatus.ACTIVE) { return { success: false, error: { code: 'INVALID_STATUS', message: 'Can only pause active promotions' }, }; } promotion.status = PromotionStatus.PAUSED; promotion.updatedBy = userId; const saved = await this.repository.save(promotion); return { success: true, data: saved }; } /** * End promotion */ async endPromotion( tenantId: string, id: string, userId: string ): Promise> { const promotion = await this.repository.findOne({ where: { id, tenantId }, }); if (!promotion) { return { success: false, error: { code: 'NOT_FOUND', message: 'Promotion not found' }, }; } if ([PromotionStatus.ENDED, PromotionStatus.CANCELLED].includes(promotion.status)) { return { success: false, error: { code: 'INVALID_STATUS', message: 'Promotion is already ended or cancelled' }, }; } promotion.status = PromotionStatus.ENDED; promotion.endDate = new Date(); promotion.updatedBy = userId; const saved = await this.repository.save(promotion); return { success: true, data: saved }; } /** * Cancel promotion */ async cancelPromotion( tenantId: string, id: string, userId: string ): Promise> { const promotion = await this.repository.findOne({ where: { id, tenantId }, }); if (!promotion) { return { success: false, error: { code: 'NOT_FOUND', message: 'Promotion not found' }, }; } if (promotion.status === PromotionStatus.CANCELLED) { return { success: false, error: { code: 'ALREADY_CANCELLED', message: 'Promotion is already cancelled' }, }; } promotion.status = PromotionStatus.CANCELLED; promotion.updatedBy = userId; const saved = await this.repository.save(promotion); return { success: true, data: saved }; } /** * Add products to promotion */ async addProducts( tenantId: string, promotionId: string, products: PromotionProductInput[], userId: string ): Promise> { const promotion = await this.repository.findOne({ where: { id: promotionId, tenantId }, }); if (!promotion) { return { success: false, error: { code: 'NOT_FOUND', message: 'Promotion not found' }, }; } if (promotion.status === PromotionStatus.ACTIVE) { return { success: false, error: { code: 'PROMOTION_ACTIVE', message: 'Cannot add products to active promotion' }, }; } const created: PromotionProduct[] = []; for (const productInput of products) { // Check if already exists const existing = await this.productRepository.findOne({ where: { tenantId, promotionId, productId: productInput.productId, variantId: productInput.variantId ?? undefined, }, }); if (existing) continue; const promotionProduct = this.productRepository.create({ tenantId, promotionId, productId: productInput.productId, productCode: productInput.productCode, productName: productInput.productName, variantId: productInput.variantId, role: productInput.role ?? ProductRole.BOTH, discountPercent: productInput.discountPercent, discountAmount: productInput.discountAmount, fixedPrice: productInput.fixedPrice, minQuantity: productInput.minQuantity ?? 1, maxQuantity: productInput.maxQuantity, bundleQuantity: productInput.bundleQuantity ?? 1, getQuantity: productInput.getQuantity, }); const saved = await this.productRepository.save(promotionProduct); created.push(saved); } return { success: true, data: created }; } /** * Remove product from promotion */ async removeProduct( tenantId: string, promotionId: string, productId: string ): Promise> { const promotion = await this.repository.findOne({ where: { id: promotionId, tenantId }, }); if (!promotion) { return { success: false, error: { code: 'NOT_FOUND', message: 'Promotion not found' }, }; } if (promotion.status === PromotionStatus.ACTIVE) { return { success: false, error: { code: 'PROMOTION_ACTIVE', message: 'Cannot remove products from active promotion' }, }; } await this.productRepository.delete({ tenantId, promotionId, productId, }); return { success: true, data: true }; } /** * Find promotions with filters */ async findPromotions( tenantId: string, options: PromotionQueryOptions ): Promise<{ data: Promotion[]; total: number }> { const qb = this.repository.createQueryBuilder('promotion') .leftJoinAndSelect('promotion.products', 'products') .where('promotion.tenantId = :tenantId', { tenantId }); if (options.status) { qb.andWhere('promotion.status = :status', { status: options.status }); } if (options.type) { qb.andWhere('promotion.type = :type', { type: options.type }); } if (options.branchId) { qb.andWhere('(promotion.includedBranches IS NULL OR promotion.includedBranches @> :branchArray)', { branchArray: JSON.stringify([options.branchId]), }); } if (options.activeOnly) { const now = new Date(); qb.andWhere('promotion.status = :activeStatus', { activeStatus: PromotionStatus.ACTIVE }) .andWhere('promotion.startDate <= :now', { now }) .andWhere('(promotion.endDate IS NULL OR promotion.endDate >= :now)', { now }); } if (options.startDate && options.endDate) { qb.andWhere('promotion.startDate BETWEEN :startDate AND :endDate', { startDate: options.startDate, endDate: options.endDate, }); } const page = options.page ?? 1; const limit = options.limit ?? 20; qb.skip((page - 1) * limit).take(limit); qb.orderBy('promotion.priority', 'DESC') .addOrderBy('promotion.createdAt', 'DESC'); const [data, total] = await qb.getManyAndCount(); return { data, total }; } /** * Get promotion with products */ async getPromotionWithProducts( tenantId: string, id: string ): Promise { return this.repository.findOne({ where: { id, tenantId }, relations: ['products'], }); } /** * Increment usage count */ async incrementUsage( tenantId: string, id: string ): Promise { await this.repository.increment( { id, tenantId }, 'currentUses', 1 ); } /** * Get promotion statistics */ async getPromotionStats( tenantId: string, id: string ): Promise<{ totalUses: number; totalDiscount: number; averageDiscount: number; }> { const promotion = await this.repository.findOne({ where: { id, tenantId }, }); if (!promotion) { return { totalUses: 0, totalDiscount: 0, averageDiscount: 0 }; } // Would need to query order data for actual discount amounts // For now, return basic usage stats return { totalUses: promotion.currentUses, totalDiscount: 0, // Would calculate from orders averageDiscount: 0, }; } }