erp-core-backend-v2/src/modules/core/discount-rules.service.ts
rckrdmrd 6054102774 [BACKEND] feat: EPIC-001 & EPIC-002 - Core module completion and entity consolidation
EPIC-001: Complete Core Module
- Add PaymentTerm entity with multi-line support (30/60/90 days, early payment discounts)
- Add PaymentTerms service with calculateDueDate() functionality
- Add DiscountRule entity with volume/time-based conditions
- Add DiscountRules service with applyDiscounts() and rule combination logic
- Add REST endpoints for payment-terms and discount-rules in core module
- Register new entities in TypeORM configuration

EPIC-002: Entity Consolidation
- Add inventoryProductId FK to products.products for linking to inventory module
- Consolidate Warehouse entity in warehouses module as canonical source
- Add companyId and Location relation to canonical Warehouse
- Update inventory module to re-export Warehouse from warehouses module
- Remove deprecated warehouse.entity.ts from inventory module
- Update inventory/warehouses.service.ts to use canonical Warehouse

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 05:13:34 -06:00

528 lines
17 KiB
TypeScript

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<DiscountRule>;
constructor() {
this.repository = AppDataSource.getRepository(DiscountRule);
}
/**
* Apply applicable discount rules to a context
*/
async applyDiscounts(
tenantId: string,
context: ApplyDiscountContext
): Promise<ApplyDiscountsResult> {
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<DiscountRule[]> {
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<void> {
await this.repository.increment({ id: ruleId }, 'usageCount', 1);
}
/**
* Get all discount rules for a tenant
*/
async findAll(tenantId: string, activeOnly: boolean = false): Promise<DiscountRule[]> {
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<DiscountRule> {
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<DiscountRule | null> {
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<DiscountRule> {
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<DiscountRule> {
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<void> {
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<DiscountRule> {
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<DiscountRule[]> {
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<DiscountRule[]> {
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();