import { Repository, LessThanOrEqual, MoreThanOrEqual, IsNull, Or } from 'typeorm'; import { AppDataSource } from '../../config/typeorm.js'; import { DiscountRule, DiscountType, DiscountAppliesTo, DiscountCondition, } from './entities/discount-rule.entity.js'; import { NotFoundError, ValidationError, ConflictError } from '../../shared/errors/index.js'; import { logger } from '../../shared/utils/logger.js'; // ============================================================================ // TYPES // ============================================================================ export interface CreateDiscountRuleDto { code: string; name: string; description?: string; discount_type?: DiscountType | 'percentage' | 'fixed' | 'price_override'; discountType?: DiscountType | 'percentage' | 'fixed' | 'price_override'; discount_value: number; discountValue?: number; max_discount_amount?: number | null; maxDiscountAmount?: number | null; applies_to?: DiscountAppliesTo | 'all' | 'category' | 'product' | 'customer' | 'customer_group'; appliesTo?: DiscountAppliesTo | 'all' | 'category' | 'product' | 'customer' | 'customer_group'; applies_to_id?: string | null; appliesToId?: string | null; condition_type?: DiscountCondition | 'none' | 'min_quantity' | 'min_amount' | 'date_range' | 'first_purchase'; conditionType?: DiscountCondition | 'none' | 'min_quantity' | 'min_amount' | 'date_range' | 'first_purchase'; condition_value?: number | null; conditionValue?: number | null; start_date?: Date | string | null; startDate?: Date | string | null; end_date?: Date | string | null; endDate?: Date | string | null; priority?: number; combinable?: boolean; usage_limit?: number | null; usageLimit?: number | null; } export interface UpdateDiscountRuleDto { name?: string; description?: string | null; discount_type?: DiscountType | 'percentage' | 'fixed' | 'price_override'; discountType?: DiscountType | 'percentage' | 'fixed' | 'price_override'; discount_value?: number; discountValue?: number; max_discount_amount?: number | null; maxDiscountAmount?: number | null; applies_to?: DiscountAppliesTo | 'all' | 'category' | 'product' | 'customer' | 'customer_group'; appliesTo?: DiscountAppliesTo | 'all' | 'category' | 'product' | 'customer' | 'customer_group'; applies_to_id?: string | null; appliesToId?: string | null; condition_type?: DiscountCondition | 'none' | 'min_quantity' | 'min_amount' | 'date_range' | 'first_purchase'; conditionType?: DiscountCondition | 'none' | 'min_quantity' | 'min_amount' | 'date_range' | 'first_purchase'; condition_value?: number | null; conditionValue?: number | null; start_date?: Date | string | null; startDate?: Date | string | null; end_date?: Date | string | null; endDate?: Date | string | null; priority?: number; combinable?: boolean; usage_limit?: number | null; usageLimit?: number | null; is_active?: boolean; isActive?: boolean; } export interface ApplyDiscountContext { productId?: string; categoryId?: string; customerId?: string; customerGroupId?: string; quantity: number; unitPrice: number; totalAmount: number; isFirstPurchase?: boolean; } export interface DiscountResult { ruleId: string; ruleCode: string; ruleName: string; discountType: DiscountType; discountAmount: number; discountPercent: number; originalAmount: number; finalAmount: number; } export interface ApplyDiscountsResult { appliedDiscounts: DiscountResult[]; totalDiscount: number; originalAmount: number; finalAmount: number; } // ============================================================================ // SERVICE // ============================================================================ class DiscountRulesService { private repository: Repository; constructor() { this.repository = AppDataSource.getRepository(DiscountRule); } /** * Apply applicable discount rules to a context */ async applyDiscounts( tenantId: string, context: ApplyDiscountContext ): Promise { logger.debug('Applying discounts', { tenantId, context }); const applicableRules = await this.findApplicableRules(tenantId, context); const appliedDiscounts: DiscountResult[] = []; let runningAmount = context.totalAmount; let totalDiscount = 0; // Sort by priority (lower = higher priority) const sortedRules = applicableRules.sort((a, b) => a.priority - b.priority); for (const rule of sortedRules) { // Check if rule can be combined with already applied discounts if (appliedDiscounts.length > 0 && !rule.combinable) { logger.debug('Skipping non-combinable rule', { ruleCode: rule.code }); continue; } // Check if previous discounts are non-combinable const hasNonCombinable = appliedDiscounts.some( (d) => !sortedRules.find((r) => r.id === d.ruleId)?.combinable ); if (hasNonCombinable && !rule.combinable) { continue; } // Check usage limit if (rule.usageLimit && rule.usageCount >= rule.usageLimit) { logger.debug('Rule usage limit reached', { ruleCode: rule.code }); continue; } // Calculate discount const discountResult = this.calculateDiscount(rule, runningAmount, context); if (discountResult.discountAmount > 0) { appliedDiscounts.push(discountResult); totalDiscount += discountResult.discountAmount; runningAmount = discountResult.finalAmount; // Increment usage count await this.incrementUsageCount(rule.id); } } return { appliedDiscounts, totalDiscount, originalAmount: context.totalAmount, finalAmount: context.totalAmount - totalDiscount, }; } /** * Calculate discount for a single rule */ private calculateDiscount( rule: DiscountRule, amount: number, context: ApplyDiscountContext ): DiscountResult { let discountAmount = 0; let discountPercent = 0; switch (rule.discountType) { case DiscountType.PERCENTAGE: discountPercent = Number(rule.discountValue); discountAmount = (amount * discountPercent) / 100; break; case DiscountType.FIXED: discountAmount = Math.min(Number(rule.discountValue), amount); discountPercent = (discountAmount / amount) * 100; break; case DiscountType.PRICE_OVERRIDE: const newPrice = Number(rule.discountValue); const totalNewAmount = newPrice * context.quantity; discountAmount = Math.max(0, amount - totalNewAmount); discountPercent = (discountAmount / amount) * 100; break; } // Apply max discount cap if (rule.maxDiscountAmount && discountAmount > Number(rule.maxDiscountAmount)) { discountAmount = Number(rule.maxDiscountAmount); discountPercent = (discountAmount / amount) * 100; } return { ruleId: rule.id, ruleCode: rule.code, ruleName: rule.name, discountType: rule.discountType, discountAmount: Math.round(discountAmount * 100) / 100, discountPercent: Math.round(discountPercent * 100) / 100, originalAmount: amount, finalAmount: Math.round((amount - discountAmount) * 100) / 100, }; } /** * Find all applicable rules for a context */ private async findApplicableRules( tenantId: string, context: ApplyDiscountContext ): Promise { const now = new Date(); const queryBuilder = this.repository .createQueryBuilder('dr') .where('dr.tenant_id = :tenantId', { tenantId }) .andWhere('dr.is_active = :isActive', { isActive: true }) .andWhere('(dr.start_date IS NULL OR dr.start_date <= :now)', { now }) .andWhere('(dr.end_date IS NULL OR dr.end_date >= :now)', { now }); const allRules = await queryBuilder.getMany(); // Filter by applies_to and condition return allRules.filter((rule) => { // Check applies_to if (!this.checkAppliesTo(rule, context)) { return false; } // Check condition if (!this.checkCondition(rule, context)) { return false; } return true; }); } /** * Check if rule applies to the context */ private checkAppliesTo(rule: DiscountRule, context: ApplyDiscountContext): boolean { switch (rule.appliesTo) { case DiscountAppliesTo.ALL: return true; case DiscountAppliesTo.PRODUCT: return rule.appliesToId === context.productId; case DiscountAppliesTo.CATEGORY: return rule.appliesToId === context.categoryId; case DiscountAppliesTo.CUSTOMER: return rule.appliesToId === context.customerId; case DiscountAppliesTo.CUSTOMER_GROUP: return rule.appliesToId === context.customerGroupId; default: return false; } } /** * Check if rule condition is met */ private checkCondition(rule: DiscountRule, context: ApplyDiscountContext): boolean { switch (rule.conditionType) { case DiscountCondition.NONE: return true; case DiscountCondition.MIN_QUANTITY: return context.quantity >= Number(rule.conditionValue || 0); case DiscountCondition.MIN_AMOUNT: return context.totalAmount >= Number(rule.conditionValue || 0); case DiscountCondition.DATE_RANGE: // Already handled in query return true; case DiscountCondition.FIRST_PURCHASE: return context.isFirstPurchase === true; default: return true; } } /** * Increment usage count for a rule */ private async incrementUsageCount(ruleId: string): Promise { await this.repository.increment({ id: ruleId }, 'usageCount', 1); } /** * Get all discount rules for a tenant */ async findAll(tenantId: string, activeOnly: boolean = false): Promise { logger.debug('Finding all discount rules', { tenantId, activeOnly }); const query = this.repository .createQueryBuilder('dr') .where('dr.tenant_id = :tenantId', { tenantId }) .orderBy('dr.priority', 'ASC') .addOrderBy('dr.name', 'ASC'); if (activeOnly) { query.andWhere('dr.is_active = :isActive', { isActive: true }); } return query.getMany(); } /** * Get a specific discount rule by ID */ async findById(id: string, tenantId: string): Promise { logger.debug('Finding discount rule by id', { id, tenantId }); const rule = await this.repository.findOne({ where: { id, tenantId }, }); if (!rule) { throw new NotFoundError('Regla de descuento no encontrada'); } return rule; } /** * Get a specific discount rule by code */ async findByCode(code: string, tenantId: string): Promise { logger.debug('Finding discount rule by code', { code, tenantId }); return this.repository.findOne({ where: { code, tenantId }, }); } /** * Create a new discount rule */ async create( dto: CreateDiscountRuleDto, tenantId: string, userId?: string ): Promise { logger.debug('Creating discount rule', { dto, tenantId }); // Check for existing const existing = await this.findByCode(dto.code, tenantId); if (existing) { throw new ConflictError(`Ya existe una regla de descuento con código ${dto.code}`); } // Normalize inputs const discountTypeRaw = dto.discount_type ?? dto.discountType ?? 'percentage'; const discountType = discountTypeRaw as DiscountType; const discountValue = dto.discount_value ?? dto.discountValue; const maxDiscountAmount = dto.max_discount_amount ?? dto.maxDiscountAmount ?? null; const appliesToRaw = dto.applies_to ?? dto.appliesTo ?? 'all'; const appliesTo = appliesToRaw as DiscountAppliesTo; const appliesToId = dto.applies_to_id ?? dto.appliesToId ?? null; const conditionTypeRaw = dto.condition_type ?? dto.conditionType ?? 'none'; const conditionType = conditionTypeRaw as DiscountCondition; const conditionValue = dto.condition_value ?? dto.conditionValue ?? null; const startDate = dto.start_date ?? dto.startDate ?? null; const endDate = dto.end_date ?? dto.endDate ?? null; const usageLimit = dto.usage_limit ?? dto.usageLimit ?? null; if (discountValue === undefined) { throw new ValidationError('discount_value es requerido'); } const rule = this.repository.create({ tenantId, code: dto.code, name: dto.name, description: dto.description || null, discountType, discountValue, maxDiscountAmount, appliesTo, appliesToId, conditionType, conditionValue, startDate: startDate ? new Date(startDate) : null, endDate: endDate ? new Date(endDate) : null, priority: dto.priority ?? 10, combinable: dto.combinable ?? true, usageLimit, createdBy: userId || null, }); const saved = await this.repository.save(rule); logger.info('Discount rule created', { id: saved.id, code: dto.code, tenantId }); return saved; } /** * Update a discount rule */ async update( id: string, dto: UpdateDiscountRuleDto, tenantId: string, userId?: string ): Promise { logger.debug('Updating discount rule', { id, dto, tenantId }); const existing = await this.findById(id, tenantId); // Normalize inputs const discountTypeRaw = dto.discount_type ?? dto.discountType; const discountValue = dto.discount_value ?? dto.discountValue; const maxDiscountAmount = dto.max_discount_amount ?? dto.maxDiscountAmount; const appliesToRaw = dto.applies_to ?? dto.appliesTo; const appliesToId = dto.applies_to_id ?? dto.appliesToId; const conditionTypeRaw = dto.condition_type ?? dto.conditionType; const conditionValue = dto.condition_value ?? dto.conditionValue; const startDate = dto.start_date ?? dto.startDate; const endDate = dto.end_date ?? dto.endDate; const usageLimit = dto.usage_limit ?? dto.usageLimit; const isActive = dto.is_active ?? dto.isActive; if (dto.name !== undefined) existing.name = dto.name; if (dto.description !== undefined) existing.description = dto.description; if (discountTypeRaw !== undefined) existing.discountType = discountTypeRaw as DiscountType; if (discountValue !== undefined) existing.discountValue = discountValue; if (maxDiscountAmount !== undefined) existing.maxDiscountAmount = maxDiscountAmount; if (appliesToRaw !== undefined) existing.appliesTo = appliesToRaw as DiscountAppliesTo; if (appliesToId !== undefined) existing.appliesToId = appliesToId; if (conditionTypeRaw !== undefined) existing.conditionType = conditionTypeRaw as DiscountCondition; if (conditionValue !== undefined) existing.conditionValue = conditionValue; if (startDate !== undefined) existing.startDate = startDate ? new Date(startDate) : null; if (endDate !== undefined) existing.endDate = endDate ? new Date(endDate) : null; if (dto.priority !== undefined) existing.priority = dto.priority; if (dto.combinable !== undefined) existing.combinable = dto.combinable; if (usageLimit !== undefined) existing.usageLimit = usageLimit; if (isActive !== undefined) existing.isActive = isActive; existing.updatedBy = userId || null; const updated = await this.repository.save(existing); logger.info('Discount rule updated', { id, tenantId }); return updated; } /** * Soft delete a discount rule */ async delete(id: string, tenantId: string, userId?: string): Promise { logger.debug('Deleting discount rule', { id, tenantId }); const existing = await this.findById(id, tenantId); existing.deletedAt = new Date(); existing.deletedBy = userId || null; await this.repository.save(existing); logger.info('Discount rule deleted', { id, tenantId }); } /** * Reset usage count for a rule */ async resetUsageCount(id: string, tenantId: string): Promise { logger.debug('Resetting usage count', { id, tenantId }); const rule = await this.findById(id, tenantId); rule.usageCount = 0; return this.repository.save(rule); } /** * Find rules by product */ async findByProduct(productId: string, tenantId: string): Promise { return this.repository.find({ where: [ { tenantId, appliesTo: DiscountAppliesTo.PRODUCT, appliesToId: productId, isActive: true }, { tenantId, appliesTo: DiscountAppliesTo.ALL, isActive: true }, ], order: { priority: 'ASC' }, }); } /** * Find rules by customer */ async findByCustomer(customerId: string, tenantId: string): Promise { return this.repository.find({ where: [ { tenantId, appliesTo: DiscountAppliesTo.CUSTOMER, appliesToId: customerId, isActive: true }, { tenantId, appliesTo: DiscountAppliesTo.ALL, isActive: true }, ], order: { priority: 'ASC' }, }); } } export const discountRulesService = new DiscountRulesService();