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>
528 lines
17 KiB
TypeScript
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();
|