[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>
This commit is contained in:
rckrdmrd 2026-01-18 05:13:34 -06:00
parent a7bf403367
commit 6054102774
16 changed files with 1775 additions and 114 deletions

View File

@ -34,6 +34,9 @@ import {
Uom,
ProductCategory,
Sequence,
PaymentTerm,
PaymentTermLine,
DiscountRule,
} from '../modules/core/entities/index.js';
// Import Financial Entities
@ -109,6 +112,9 @@ export const AppDataSource = new DataSource({
Uom,
ProductCategory,
Sequence,
PaymentTerm,
PaymentTermLine,
DiscountRule,
// Financial Entities
AccountType,
Account,

View File

@ -4,6 +4,10 @@ import { currenciesService, CreateCurrencyDto, UpdateCurrencyDto } from './curre
import { countriesService } from './countries.service.js';
import { uomService, CreateUomDto, UpdateUomDto } from './uom.service.js';
import { productCategoriesService, CreateProductCategoryDto, UpdateProductCategoryDto } from './product-categories.service.js';
import { paymentTermsService, CreatePaymentTermDto, UpdatePaymentTermDto } from './payment-terms.service.js';
import { discountRulesService, CreateDiscountRuleDto, UpdateDiscountRuleDto, ApplyDiscountContext } from './discount-rules.service.js';
import { PaymentTermLineType } from './entities/payment-term.entity.js';
import { DiscountType, DiscountAppliesTo, DiscountCondition } from './entities/discount-rule.entity.js';
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
import { ValidationError } from '../../shared/errors/index.js';
@ -58,6 +62,136 @@ const updateCategorySchema = z.object({
active: z.boolean().optional(),
});
// Payment Terms Schemas
const paymentTermLineSchema = z.object({
sequence: z.number().int().min(1).optional(),
line_type: z.enum(['balance', 'percent', 'fixed']).optional(),
lineType: z.enum(['balance', 'percent', 'fixed']).optional(),
value_percent: z.number().min(0).max(100).optional(),
valuePercent: z.number().min(0).max(100).optional(),
value_amount: z.number().min(0).optional(),
valueAmount: z.number().min(0).optional(),
days: z.number().int().min(0).optional(),
day_of_month: z.number().int().min(1).max(31).optional(),
dayOfMonth: z.number().int().min(1).max(31).optional(),
end_of_month: z.boolean().optional(),
endOfMonth: z.boolean().optional(),
});
const createPaymentTermSchema = z.object({
code: z.string().min(1).max(50),
name: z.string().min(1).max(255),
description: z.string().optional(),
due_days: z.number().int().min(0).optional(),
dueDays: z.number().int().min(0).optional(),
discount_percent: z.number().min(0).max(100).optional(),
discountPercent: z.number().min(0).max(100).optional(),
discount_days: z.number().int().min(0).optional(),
discountDays: z.number().int().min(0).optional(),
is_immediate: z.boolean().optional(),
isImmediate: z.boolean().optional(),
lines: z.array(paymentTermLineSchema).optional(),
});
const updatePaymentTermSchema = z.object({
name: z.string().min(1).max(255).optional(),
description: z.string().optional().nullable(),
due_days: z.number().int().min(0).optional(),
dueDays: z.number().int().min(0).optional(),
discount_percent: z.number().min(0).max(100).optional().nullable(),
discountPercent: z.number().min(0).max(100).optional().nullable(),
discount_days: z.number().int().min(0).optional().nullable(),
discountDays: z.number().int().min(0).optional().nullable(),
is_immediate: z.boolean().optional(),
isImmediate: z.boolean().optional(),
is_active: z.boolean().optional(),
isActive: z.boolean().optional(),
lines: z.array(paymentTermLineSchema).optional(),
});
const calculateDueDateSchema = z.object({
invoice_date: z.string().datetime().optional(),
invoiceDate: z.string().datetime().optional(),
total_amount: z.number().min(0),
totalAmount: z.number().min(0).optional(),
});
// Discount Rules Schemas
const createDiscountRuleSchema = z.object({
code: z.string().min(1).max(50),
name: z.string().min(1).max(255),
description: z.string().optional(),
discount_type: z.enum(['percentage', 'fixed', 'price_override']).optional(),
discountType: z.enum(['percentage', 'fixed', 'price_override']).optional(),
discount_value: z.number().min(0),
discountValue: z.number().min(0).optional(),
max_discount_amount: z.number().min(0).optional().nullable(),
maxDiscountAmount: z.number().min(0).optional().nullable(),
applies_to: z.enum(['all', 'category', 'product', 'customer', 'customer_group']).optional(),
appliesTo: z.enum(['all', 'category', 'product', 'customer', 'customer_group']).optional(),
applies_to_id: z.string().uuid().optional().nullable(),
appliesToId: z.string().uuid().optional().nullable(),
condition_type: z.enum(['none', 'min_quantity', 'min_amount', 'date_range', 'first_purchase']).optional(),
conditionType: z.enum(['none', 'min_quantity', 'min_amount', 'date_range', 'first_purchase']).optional(),
condition_value: z.number().optional().nullable(),
conditionValue: z.number().optional().nullable(),
start_date: z.string().datetime().optional().nullable(),
startDate: z.string().datetime().optional().nullable(),
end_date: z.string().datetime().optional().nullable(),
endDate: z.string().datetime().optional().nullable(),
priority: z.number().int().min(0).optional(),
combinable: z.boolean().optional(),
usage_limit: z.number().int().min(0).optional().nullable(),
usageLimit: z.number().int().min(0).optional().nullable(),
});
const updateDiscountRuleSchema = z.object({
name: z.string().min(1).max(255).optional(),
description: z.string().optional().nullable(),
discount_type: z.enum(['percentage', 'fixed', 'price_override']).optional(),
discountType: z.enum(['percentage', 'fixed', 'price_override']).optional(),
discount_value: z.number().min(0).optional(),
discountValue: z.number().min(0).optional(),
max_discount_amount: z.number().min(0).optional().nullable(),
maxDiscountAmount: z.number().min(0).optional().nullable(),
applies_to: z.enum(['all', 'category', 'product', 'customer', 'customer_group']).optional(),
appliesTo: z.enum(['all', 'category', 'product', 'customer', 'customer_group']).optional(),
applies_to_id: z.string().uuid().optional().nullable(),
appliesToId: z.string().uuid().optional().nullable(),
condition_type: z.enum(['none', 'min_quantity', 'min_amount', 'date_range', 'first_purchase']).optional(),
conditionType: z.enum(['none', 'min_quantity', 'min_amount', 'date_range', 'first_purchase']).optional(),
condition_value: z.number().optional().nullable(),
conditionValue: z.number().optional().nullable(),
start_date: z.string().datetime().optional().nullable(),
startDate: z.string().datetime().optional().nullable(),
end_date: z.string().datetime().optional().nullable(),
endDate: z.string().datetime().optional().nullable(),
priority: z.number().int().min(0).optional(),
combinable: z.boolean().optional(),
usage_limit: z.number().int().min(0).optional().nullable(),
usageLimit: z.number().int().min(0).optional().nullable(),
is_active: z.boolean().optional(),
isActive: z.boolean().optional(),
});
const applyDiscountsSchema = z.object({
product_id: z.string().uuid().optional(),
productId: z.string().uuid().optional(),
category_id: z.string().uuid().optional(),
categoryId: z.string().uuid().optional(),
customer_id: z.string().uuid().optional(),
customerId: z.string().uuid().optional(),
customer_group_id: z.string().uuid().optional(),
customerGroupId: z.string().uuid().optional(),
quantity: z.number().min(0),
unit_price: z.number().min(0),
unitPrice: z.number().min(0).optional(),
total_amount: z.number().min(0),
totalAmount: z.number().min(0).optional(),
is_first_purchase: z.boolean().optional(),
isFirstPurchase: z.boolean().optional(),
});
class CoreController {
// ========== CURRENCIES ==========
async getCurrencies(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
@ -252,6 +386,205 @@ class CoreController {
next(error);
}
}
// ========== PAYMENT TERMS ==========
async getPaymentTerms(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const activeOnly = req.query.active === 'true';
const paymentTerms = await paymentTermsService.findAll(req.tenantId!, activeOnly);
res.json({ success: true, data: paymentTerms });
} catch (error) {
next(error);
}
}
async getPaymentTerm(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const paymentTerm = await paymentTermsService.findById(req.params.id, req.tenantId!);
res.json({ success: true, data: paymentTerm });
} catch (error) {
next(error);
}
}
async createPaymentTerm(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const parseResult = createPaymentTermSchema.safeParse(req.body);
if (!parseResult.success) {
throw new ValidationError('Datos de término de pago inválidos', parseResult.error.errors);
}
const dto: CreatePaymentTermDto = parseResult.data;
const paymentTerm = await paymentTermsService.create(dto, req.tenantId!, req.user?.userId);
res.status(201).json({ success: true, data: paymentTerm, message: 'Término de pago creado exitosamente' });
} catch (error) {
next(error);
}
}
async updatePaymentTerm(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const parseResult = updatePaymentTermSchema.safeParse(req.body);
if (!parseResult.success) {
throw new ValidationError('Datos de término de pago inválidos', parseResult.error.errors);
}
const dto: UpdatePaymentTermDto = parseResult.data;
const paymentTerm = await paymentTermsService.update(req.params.id, dto, req.tenantId!, req.user?.userId);
res.json({ success: true, data: paymentTerm, message: 'Término de pago actualizado exitosamente' });
} catch (error) {
next(error);
}
}
async deletePaymentTerm(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
await paymentTermsService.delete(req.params.id, req.tenantId!, req.user?.userId);
res.json({ success: true, message: 'Término de pago eliminado exitosamente' });
} catch (error) {
next(error);
}
}
async calculateDueDate(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const parseResult = calculateDueDateSchema.safeParse(req.body);
if (!parseResult.success) {
throw new ValidationError('Datos inválidos para cálculo', parseResult.error.errors);
}
const paymentTerm = await paymentTermsService.findById(req.params.id, req.tenantId!);
const invoiceDate = parseResult.data.invoice_date ?? parseResult.data.invoiceDate ?? new Date().toISOString();
const totalAmount = parseResult.data.total_amount ?? parseResult.data.totalAmount ?? 0;
const result = paymentTermsService.calculateDueDate(paymentTerm, new Date(invoiceDate), totalAmount);
res.json({ success: true, data: result });
} catch (error) {
next(error);
}
}
async getStandardPaymentTerms(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const standardTerms = paymentTermsService.getStandardTerms();
res.json({ success: true, data: standardTerms });
} catch (error) {
next(error);
}
}
async initializePaymentTerms(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
await paymentTermsService.initializeForTenant(req.tenantId!, req.user?.userId);
res.json({ success: true, message: 'Términos de pago inicializados exitosamente' });
} catch (error) {
next(error);
}
}
// ========== DISCOUNT RULES ==========
async getDiscountRules(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const activeOnly = req.query.active === 'true';
const discountRules = await discountRulesService.findAll(req.tenantId!, activeOnly);
res.json({ success: true, data: discountRules });
} catch (error) {
next(error);
}
}
async getDiscountRule(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const discountRule = await discountRulesService.findById(req.params.id, req.tenantId!);
res.json({ success: true, data: discountRule });
} catch (error) {
next(error);
}
}
async createDiscountRule(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const parseResult = createDiscountRuleSchema.safeParse(req.body);
if (!parseResult.success) {
throw new ValidationError('Datos de regla de descuento inválidos', parseResult.error.errors);
}
const dto: CreateDiscountRuleDto = parseResult.data;
const discountRule = await discountRulesService.create(dto, req.tenantId!, req.user?.userId);
res.status(201).json({ success: true, data: discountRule, message: 'Regla de descuento creada exitosamente' });
} catch (error) {
next(error);
}
}
async updateDiscountRule(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const parseResult = updateDiscountRuleSchema.safeParse(req.body);
if (!parseResult.success) {
throw new ValidationError('Datos de regla de descuento inválidos', parseResult.error.errors);
}
const dto: UpdateDiscountRuleDto = parseResult.data;
const discountRule = await discountRulesService.update(req.params.id, dto, req.tenantId!, req.user?.userId);
res.json({ success: true, data: discountRule, message: 'Regla de descuento actualizada exitosamente' });
} catch (error) {
next(error);
}
}
async deleteDiscountRule(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
await discountRulesService.delete(req.params.id, req.tenantId!, req.user?.userId);
res.json({ success: true, message: 'Regla de descuento eliminada exitosamente' });
} catch (error) {
next(error);
}
}
async applyDiscounts(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const parseResult = applyDiscountsSchema.safeParse(req.body);
if (!parseResult.success) {
throw new ValidationError('Datos inválidos para aplicar descuentos', parseResult.error.errors);
}
const data = parseResult.data;
const context: ApplyDiscountContext = {
productId: data.product_id ?? data.productId,
categoryId: data.category_id ?? data.categoryId,
customerId: data.customer_id ?? data.customerId,
customerGroupId: data.customer_group_id ?? data.customerGroupId,
quantity: data.quantity,
unitPrice: data.unit_price ?? data.unitPrice ?? 0,
totalAmount: data.total_amount ?? data.totalAmount ?? 0,
isFirstPurchase: data.is_first_purchase ?? data.isFirstPurchase,
};
const result = await discountRulesService.applyDiscounts(req.tenantId!, context);
res.json({ success: true, data: result });
} catch (error) {
next(error);
}
}
async resetDiscountRuleUsage(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const discountRule = await discountRulesService.resetUsageCount(req.params.id, req.tenantId!);
res.json({ success: true, data: discountRule, message: 'Contador de uso reiniciado exitosamente' });
} catch (error) {
next(error);
}
}
async getDiscountRulesByProduct(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const discountRules = await discountRulesService.findByProduct(req.params.productId, req.tenantId!);
res.json({ success: true, data: discountRules });
} catch (error) {
next(error);
}
}
async getDiscountRulesByCustomer(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const discountRules = await discountRulesService.findByCustomer(req.params.customerId, req.tenantId!);
res.json({ success: true, data: discountRules });
} catch (error) {
next(error);
}
}
}
export const coreController = new CoreController();

View File

@ -48,4 +48,47 @@ router.delete('/product-categories/:id', requireRoles('admin', 'super_admin'), (
coreController.deleteProductCategory(req, res, next)
);
// ========== PAYMENT TERMS ==========
router.get('/payment-terms', (req, res, next) => coreController.getPaymentTerms(req, res, next));
router.get('/payment-terms/standard', (req, res, next) => coreController.getStandardPaymentTerms(req, res, next));
router.get('/payment-terms/:id', (req, res, next) => coreController.getPaymentTerm(req, res, next));
router.post('/payment-terms', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
coreController.createPaymentTerm(req, res, next)
);
router.post('/payment-terms/initialize', requireRoles('admin', 'super_admin'), (req, res, next) =>
coreController.initializePaymentTerms(req, res, next)
);
router.post('/payment-terms/:id/calculate-due-date', (req, res, next) =>
coreController.calculateDueDate(req, res, next)
);
router.put('/payment-terms/:id', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
coreController.updatePaymentTerm(req, res, next)
);
router.delete('/payment-terms/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
coreController.deletePaymentTerm(req, res, next)
);
// ========== DISCOUNT RULES ==========
router.get('/discount-rules', (req, res, next) => coreController.getDiscountRules(req, res, next));
router.get('/discount-rules/by-product/:productId', (req, res, next) =>
coreController.getDiscountRulesByProduct(req, res, next)
);
router.get('/discount-rules/by-customer/:customerId', (req, res, next) =>
coreController.getDiscountRulesByCustomer(req, res, next)
);
router.get('/discount-rules/:id', (req, res, next) => coreController.getDiscountRule(req, res, next));
router.post('/discount-rules', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
coreController.createDiscountRule(req, res, next)
);
router.post('/discount-rules/apply', (req, res, next) => coreController.applyDiscounts(req, res, next));
router.put('/discount-rules/:id', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
coreController.updateDiscountRule(req, res, next)
);
router.post('/discount-rules/:id/reset-usage', requireRoles('admin', 'super_admin'), (req, res, next) =>
coreController.resetDiscountRuleUsage(req, res, next)
);
router.delete('/discount-rules/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
coreController.deleteDiscountRule(req, res, next)
);
export default router;

View File

@ -0,0 +1,527 @@
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();

View File

@ -0,0 +1,163 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
DeleteDateColumn,
Index,
} from 'typeorm';
/**
* Tipo de descuento
*/
export enum DiscountType {
PERCENTAGE = 'percentage', // Porcentaje del total
FIXED = 'fixed', // Monto fijo
PRICE_OVERRIDE = 'price_override', // Precio especial
}
/**
* Aplicación del descuento
*/
export enum DiscountAppliesTo {
ALL = 'all', // Todos los productos
CATEGORY = 'category', // Categoría específica
PRODUCT = 'product', // Producto específico
CUSTOMER = 'customer', // Cliente específico
CUSTOMER_GROUP = 'customer_group', // Grupo de clientes
}
/**
* Condición de activación
*/
export enum DiscountCondition {
NONE = 'none', // Sin condición
MIN_QUANTITY = 'min_quantity', // Cantidad mínima
MIN_AMOUNT = 'min_amount', // Monto mínimo
DATE_RANGE = 'date_range', // Rango de fechas
FIRST_PURCHASE = 'first_purchase', // Primera compra
}
/**
* Regla de descuento
*/
@Entity({ schema: 'core', name: 'discount_rules' })
@Index('idx_discount_rules_tenant_id', ['tenantId'])
@Index('idx_discount_rules_code_tenant', ['tenantId', 'code'], { unique: true })
@Index('idx_discount_rules_active', ['tenantId', 'isActive'])
@Index('idx_discount_rules_dates', ['tenantId', 'startDate', 'endDate'])
@Index('idx_discount_rules_priority', ['tenantId', 'priority'])
export class DiscountRule {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
tenantId: string;
@Column({ type: 'uuid', nullable: true, name: 'company_id' })
companyId: string | null;
@Column({ type: 'varchar', length: 50, nullable: false })
code: string;
@Column({ type: 'varchar', length: 255, nullable: false })
name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({
type: 'enum',
enum: DiscountType,
default: DiscountType.PERCENTAGE,
name: 'discount_type',
})
discountType: DiscountType;
@Column({
type: 'decimal',
precision: 15,
scale: 4,
nullable: false,
name: 'discount_value',
})
discountValue: number;
@Column({
type: 'decimal',
precision: 15,
scale: 2,
nullable: true,
name: 'max_discount_amount',
})
maxDiscountAmount: number | null;
@Column({
type: 'enum',
enum: DiscountAppliesTo,
default: DiscountAppliesTo.ALL,
name: 'applies_to',
})
appliesTo: DiscountAppliesTo;
@Column({ type: 'uuid', nullable: true, name: 'applies_to_id' })
appliesToId: string | null;
@Column({
type: 'enum',
enum: DiscountCondition,
default: DiscountCondition.NONE,
name: 'condition_type',
})
conditionType: DiscountCondition;
@Column({
type: 'decimal',
precision: 15,
scale: 4,
nullable: true,
name: 'condition_value',
})
conditionValue: number | null;
@Column({ type: 'timestamp', nullable: true, name: 'start_date' })
startDate: Date | null;
@Column({ type: 'timestamp', nullable: true, name: 'end_date' })
endDate: Date | null;
@Column({ type: 'integer', nullable: false, default: 10 })
priority: number;
@Column({ type: 'boolean', nullable: false, default: true, name: 'combinable' })
combinable: boolean;
@Column({ type: 'integer', nullable: true, name: 'usage_limit' })
usageLimit: number | null;
@Column({ type: 'integer', nullable: false, default: 0, name: 'usage_count' })
usageCount: number;
@Column({ type: 'boolean', nullable: false, default: true, name: 'is_active' })
isActive: boolean;
// Audit fields
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
createdBy: string | null;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp', nullable: true })
updatedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
updatedBy: string | null;
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
deletedBy: string | null;
}

View File

@ -4,3 +4,5 @@ export { UomCategory } from './uom-category.entity.js';
export { Uom, UomType } from './uom.entity.js';
export { ProductCategory } from './product-category.entity.js';
export { Sequence, ResetPeriod } from './sequence.entity.js';
export { PaymentTerm, PaymentTermLine, PaymentTermLineType } from './payment-term.entity.js';
export { DiscountRule, DiscountType, DiscountAppliesTo, DiscountCondition } from './discount-rule.entity.js';

View File

@ -0,0 +1,144 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
DeleteDateColumn,
Index,
OneToMany,
} from 'typeorm';
/**
* Tipo de cálculo para la línea del término de pago
*/
export enum PaymentTermLineType {
BALANCE = 'balance', // Saldo restante
PERCENT = 'percent', // Porcentaje del total
FIXED = 'fixed', // Monto fijo
}
/**
* Línea de término de pago (para términos con múltiples vencimientos)
*/
@Entity({ schema: 'core', name: 'payment_term_lines' })
@Index('idx_payment_term_lines_term', ['paymentTermId'])
export class PaymentTermLine {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false, name: 'payment_term_id' })
paymentTermId: string;
@Column({ type: 'integer', nullable: false, default: 1 })
sequence: number;
@Column({
type: 'enum',
enum: PaymentTermLineType,
default: PaymentTermLineType.BALANCE,
name: 'line_type',
})
lineType: PaymentTermLineType;
@Column({
type: 'decimal',
precision: 5,
scale: 2,
nullable: true,
name: 'value_percent',
})
valuePercent: number | null;
@Column({
type: 'decimal',
precision: 15,
scale: 2,
nullable: true,
name: 'value_amount',
})
valueAmount: number | null;
@Column({ type: 'integer', nullable: false, default: 0 })
days: number;
@Column({ type: 'integer', nullable: true, name: 'day_of_month' })
dayOfMonth: number | null;
@Column({ type: 'boolean', nullable: false, default: false, name: 'end_of_month' })
endOfMonth: boolean;
}
/**
* Término de pago (Net 30, 50% advance + 50% on delivery, etc.)
*/
@Entity({ schema: 'core', name: 'payment_terms' })
@Index('idx_payment_terms_tenant_id', ['tenantId'])
@Index('idx_payment_terms_code_tenant', ['tenantId', 'code'], { unique: true })
@Index('idx_payment_terms_active', ['tenantId', 'isActive'])
export class PaymentTerm {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
tenantId: string;
@Column({ type: 'uuid', nullable: true, name: 'company_id' })
companyId: string | null;
@Column({ type: 'varchar', length: 50, nullable: false })
code: string;
@Column({ type: 'varchar', length: 255, nullable: false })
name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'integer', nullable: false, default: 0, name: 'due_days' })
dueDays: number;
@Column({
type: 'decimal',
precision: 5,
scale: 2,
nullable: true,
default: 0,
name: 'discount_percent',
})
discountPercent: number | null;
@Column({ type: 'integer', nullable: true, default: 0, name: 'discount_days' })
discountDays: number | null;
@Column({ type: 'boolean', nullable: false, default: false, name: 'is_immediate' })
isImmediate: boolean;
@Column({ type: 'boolean', nullable: false, default: true, name: 'is_active' })
isActive: boolean;
@Column({ type: 'integer', nullable: false, default: 0 })
sequence: number;
@OneToMany(() => PaymentTermLine, (line) => line.paymentTermId, { eager: true })
lines: PaymentTermLine[];
// Audit fields
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
createdBy: string | null;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp', nullable: true })
updatedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
updatedBy: string | null;
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
deletedBy: string | null;
}

View File

@ -3,6 +3,8 @@ export * from './countries.service.js';
export * from './uom.service.js';
export * from './product-categories.service.js';
export * from './sequences.service.js';
export * from './payment-terms.service.js';
export * from './discount-rules.service.js';
export * from './entities/index.js';
export * from './core.controller.js';
export { default as coreRoutes } from './core.routes.js';

View File

@ -0,0 +1,461 @@
import { Repository } from 'typeorm';
import { AppDataSource } from '../../config/typeorm.js';
import {
PaymentTerm,
PaymentTermLine,
PaymentTermLineType,
} from './entities/payment-term.entity.js';
import { NotFoundError, ValidationError, ConflictError } from '../../shared/errors/index.js';
import { logger } from '../../shared/utils/logger.js';
// ============================================================================
// TYPES
// ============================================================================
export interface CreatePaymentTermLineDto {
sequence?: number;
line_type?: PaymentTermLineType | 'balance' | 'percent' | 'fixed';
lineType?: PaymentTermLineType | 'balance' | 'percent' | 'fixed';
value_percent?: number;
valuePercent?: number;
value_amount?: number;
valueAmount?: number;
days?: number;
day_of_month?: number;
dayOfMonth?: number;
end_of_month?: boolean;
endOfMonth?: boolean;
}
export interface CreatePaymentTermDto {
code: string;
name: string;
description?: string;
due_days?: number;
dueDays?: number;
discount_percent?: number;
discountPercent?: number;
discount_days?: number;
discountDays?: number;
is_immediate?: boolean;
isImmediate?: boolean;
lines?: CreatePaymentTermLineDto[];
}
export interface UpdatePaymentTermDto {
name?: string;
description?: string | null;
due_days?: number;
dueDays?: number;
discount_percent?: number | null;
discountPercent?: number | null;
discount_days?: number | null;
discountDays?: number | null;
is_immediate?: boolean;
isImmediate?: boolean;
is_active?: boolean;
isActive?: boolean;
lines?: CreatePaymentTermLineDto[];
}
export interface DueDateResult {
dueDate: Date;
discountDate: Date | null;
discountAmount: number;
lines: Array<{
dueDate: Date;
amount: number;
percent: number;
}>;
}
// ============================================================================
// SERVICE
// ============================================================================
class PaymentTermsService {
private repository: Repository<PaymentTerm>;
private lineRepository: Repository<PaymentTermLine>;
constructor() {
this.repository = AppDataSource.getRepository(PaymentTerm);
this.lineRepository = AppDataSource.getRepository(PaymentTermLine);
}
/**
* Calculate due date(s) based on payment term
*/
calculateDueDate(
paymentTerm: PaymentTerm,
invoiceDate: Date,
totalAmount: number
): DueDateResult {
logger.debug('Calculating due date', {
termCode: paymentTerm.code,
invoiceDate,
totalAmount,
});
const baseDate = new Date(invoiceDate);
const lines: DueDateResult['lines'] = [];
// If immediate payment
if (paymentTerm.isImmediate) {
return {
dueDate: baseDate,
discountDate: null,
discountAmount: 0,
lines: [{ dueDate: baseDate, amount: totalAmount, percent: 100 }],
};
}
// If payment term has lines, use them
if (paymentTerm.lines && paymentTerm.lines.length > 0) {
let remainingAmount = totalAmount;
let lastDueDate = baseDate;
for (const line of paymentTerm.lines.sort((a, b) => a.sequence - b.sequence)) {
let lineAmount = 0;
let linePercent = 0;
if (line.lineType === PaymentTermLineType.BALANCE) {
lineAmount = remainingAmount;
linePercent = (lineAmount / totalAmount) * 100;
} else if (line.lineType === PaymentTermLineType.PERCENT && line.valuePercent) {
linePercent = Number(line.valuePercent);
lineAmount = (totalAmount * linePercent) / 100;
} else if (line.lineType === PaymentTermLineType.FIXED && line.valueAmount) {
lineAmount = Math.min(Number(line.valueAmount), remainingAmount);
linePercent = (lineAmount / totalAmount) * 100;
}
const lineDueDate = this.calculateLineDueDate(baseDate, line);
lastDueDate = lineDueDate;
lines.push({
dueDate: lineDueDate,
amount: lineAmount,
percent: linePercent,
});
remainingAmount -= lineAmount;
}
// Calculate discount date if applicable
let discountDate: Date | null = null;
let discountAmount = 0;
if (paymentTerm.discountPercent && paymentTerm.discountDays) {
discountDate = new Date(baseDate);
discountDate.setDate(discountDate.getDate() + paymentTerm.discountDays);
discountAmount = (totalAmount * Number(paymentTerm.discountPercent)) / 100;
}
return {
dueDate: lastDueDate,
discountDate,
discountAmount,
lines,
};
}
// Simple due days calculation
const dueDate = new Date(baseDate);
dueDate.setDate(dueDate.getDate() + paymentTerm.dueDays);
let discountDate: Date | null = null;
let discountAmount = 0;
if (paymentTerm.discountPercent && paymentTerm.discountDays) {
discountDate = new Date(baseDate);
discountDate.setDate(discountDate.getDate() + paymentTerm.discountDays);
discountAmount = (totalAmount * Number(paymentTerm.discountPercent)) / 100;
}
return {
dueDate,
discountDate,
discountAmount,
lines: [{ dueDate, amount: totalAmount, percent: 100 }],
};
}
/**
* Calculate due date for a specific line
*/
private calculateLineDueDate(baseDate: Date, line: PaymentTermLine): Date {
const result = new Date(baseDate);
result.setDate(result.getDate() + line.days);
// If specific day of month
if (line.dayOfMonth) {
result.setDate(line.dayOfMonth);
// If the calculated date is before base + days, move to next month
const minDate = new Date(baseDate);
minDate.setDate(minDate.getDate() + line.days);
if (result < minDate) {
result.setMonth(result.getMonth() + 1);
}
}
// If end of month
if (line.endOfMonth) {
result.setMonth(result.getMonth() + 1);
result.setDate(0); // Last day of previous month
}
return result;
}
/**
* Get all payment terms for a tenant
*/
async findAll(tenantId: string, activeOnly: boolean = false): Promise<PaymentTerm[]> {
logger.debug('Finding all payment terms', { tenantId, activeOnly });
const query = this.repository
.createQueryBuilder('pt')
.leftJoinAndSelect('pt.lines', 'lines')
.where('pt.tenant_id = :tenantId', { tenantId })
.orderBy('pt.sequence', 'ASC')
.addOrderBy('pt.name', 'ASC');
if (activeOnly) {
query.andWhere('pt.is_active = :isActive', { isActive: true });
}
return query.getMany();
}
/**
* Get a specific payment term by ID
*/
async findById(id: string, tenantId: string): Promise<PaymentTerm> {
logger.debug('Finding payment term by id', { id, tenantId });
const paymentTerm = await this.repository.findOne({
where: { id, tenantId },
relations: ['lines'],
});
if (!paymentTerm) {
throw new NotFoundError('Término de pago no encontrado');
}
return paymentTerm;
}
/**
* Get a specific payment term by code
*/
async findByCode(code: string, tenantId: string): Promise<PaymentTerm | null> {
logger.debug('Finding payment term by code', { code, tenantId });
return this.repository.findOne({
where: { code, tenantId },
relations: ['lines'],
});
}
/**
* Create a new payment term
*/
async create(
dto: CreatePaymentTermDto,
tenantId: string,
userId?: string
): Promise<PaymentTerm> {
logger.debug('Creating payment term', { dto, tenantId });
// Check for existing
const existing = await this.findByCode(dto.code, tenantId);
if (existing) {
throw new ConflictError(`Ya existe un término de pago con código ${dto.code}`);
}
// Normalize inputs (accept both snake_case and camelCase)
const dueDays = dto.due_days ?? dto.dueDays ?? 0;
const discountPercent = dto.discount_percent ?? dto.discountPercent ?? null;
const discountDays = dto.discount_days ?? dto.discountDays ?? null;
const isImmediate = dto.is_immediate ?? dto.isImmediate ?? false;
const paymentTerm = this.repository.create({
tenantId,
code: dto.code,
name: dto.name,
description: dto.description || null,
dueDays,
discountPercent,
discountDays,
isImmediate,
createdBy: userId || null,
});
const saved = await this.repository.save(paymentTerm);
// Create lines if provided
if (dto.lines && dto.lines.length > 0) {
await this.createLines(saved.id, dto.lines);
// Reload with lines
return this.findById(saved.id, tenantId);
}
logger.info('Payment term created', { id: saved.id, code: dto.code, tenantId });
return saved;
}
/**
* Create payment term lines
*/
private async createLines(
paymentTermId: string,
lines: CreatePaymentTermLineDto[]
): Promise<void> {
for (let index = 0; index < lines.length; index++) {
const line = lines[index];
const lineTypeRaw = line.line_type ?? line.lineType ?? 'balance';
const lineType = lineTypeRaw as PaymentTermLineType;
const valuePercent = line.value_percent ?? line.valuePercent ?? null;
const valueAmount = line.value_amount ?? line.valueAmount ?? null;
const dayOfMonth = line.day_of_month ?? line.dayOfMonth ?? null;
const endOfMonth = line.end_of_month ?? line.endOfMonth ?? false;
const lineEntity = this.lineRepository.create({
paymentTermId,
sequence: line.sequence ?? index + 1,
lineType,
valuePercent,
valueAmount,
days: line.days ?? 0,
dayOfMonth,
endOfMonth,
});
await this.lineRepository.save(lineEntity);
}
}
/**
* Update a payment term
*/
async update(
id: string,
dto: UpdatePaymentTermDto,
tenantId: string,
userId?: string
): Promise<PaymentTerm> {
logger.debug('Updating payment term', { id, dto, tenantId });
const existing = await this.findById(id, tenantId);
// Normalize inputs
const dueDays = dto.due_days ?? dto.dueDays;
const discountPercent = dto.discount_percent ?? dto.discountPercent;
const discountDays = dto.discount_days ?? dto.discountDays;
const isImmediate = dto.is_immediate ?? dto.isImmediate;
const isActive = dto.is_active ?? dto.isActive;
if (dto.name !== undefined) {
existing.name = dto.name;
}
if (dto.description !== undefined) {
existing.description = dto.description;
}
if (dueDays !== undefined) {
existing.dueDays = dueDays;
}
if (discountPercent !== undefined) {
existing.discountPercent = discountPercent;
}
if (discountDays !== undefined) {
existing.discountDays = discountDays;
}
if (isImmediate !== undefined) {
existing.isImmediate = isImmediate;
}
if (isActive !== undefined) {
existing.isActive = isActive;
}
existing.updatedBy = userId || null;
const updated = await this.repository.save(existing);
// Update lines if provided
if (dto.lines !== undefined) {
// Remove existing lines
await this.lineRepository.delete({ paymentTermId: id });
// Create new lines
if (dto.lines.length > 0) {
await this.createLines(id, dto.lines);
}
}
logger.info('Payment term updated', { id, tenantId });
return this.findById(id, tenantId);
}
/**
* Soft delete a payment term
*/
async delete(id: string, tenantId: string, userId?: string): Promise<void> {
logger.debug('Deleting payment term', { id, tenantId });
const existing = await this.findById(id, tenantId);
existing.deletedAt = new Date();
existing.deletedBy = userId || null;
await this.repository.save(existing);
logger.info('Payment term deleted', { id, tenantId });
}
/**
* Get common/standard payment terms
*/
getStandardTerms(): Array<{ code: string; name: string; dueDays: number; discountPercent?: number; discountDays?: number }> {
return [
{ code: 'IMMEDIATE', name: 'Pago Inmediato', dueDays: 0 },
{ code: 'NET15', name: 'Neto 15 días', dueDays: 15 },
{ code: 'NET30', name: 'Neto 30 días', dueDays: 30 },
{ code: 'NET45', name: 'Neto 45 días', dueDays: 45 },
{ code: 'NET60', name: 'Neto 60 días', dueDays: 60 },
{ code: 'NET90', name: 'Neto 90 días', dueDays: 90 },
{ code: '2/10NET30', name: '2% 10 días, Neto 30', dueDays: 30, discountPercent: 2, discountDays: 10 },
{ code: '1/10NET30', name: '1% 10 días, Neto 30', dueDays: 30, discountPercent: 1, discountDays: 10 },
];
}
/**
* Initialize standard payment terms for a tenant
*/
async initializeForTenant(tenantId: string, userId?: string): Promise<void> {
logger.debug('Initializing payment terms for tenant', { tenantId });
const standardTerms = this.getStandardTerms();
for (const term of standardTerms) {
const existing = await this.findByCode(term.code, tenantId);
if (!existing) {
await this.create(
{
code: term.code,
name: term.name,
dueDays: term.dueDays,
discountPercent: term.discountPercent,
discountDays: term.discountDays,
isImmediate: term.dueDays === 0,
},
tenantId,
userId
);
}
}
logger.info('Payment terms initialized for tenant', { tenantId, count: standardTerms.length });
}
}
export const paymentTermsService = new PaymentTermsService();

View File

@ -1,25 +1,26 @@
// Core Inventory Entities
export { Product } from './product.entity';
export { Warehouse } from './warehouse.entity';
export { Location } from './location.entity';
export { StockQuant } from './stock-quant.entity';
export { Lot } from './lot.entity';
export { Product } from './product.entity.js';
// Re-export Warehouse from canonical location in warehouses module
export { Warehouse } from '../../warehouses/entities/warehouse.entity.js';
export { Location } from './location.entity.js';
export { StockQuant } from './stock-quant.entity.js';
export { Lot } from './lot.entity.js';
// Stock Operations
export { Picking } from './picking.entity';
export { StockMove } from './stock-move.entity';
export { StockLevel } from './stock-level.entity';
export { StockMovement } from './stock-movement.entity';
export { Picking } from './picking.entity.js';
export { StockMove } from './stock-move.entity.js';
export { StockLevel } from './stock-level.entity.js';
export { StockMovement } from './stock-movement.entity.js';
// Inventory Management
export { InventoryCount } from './inventory-count.entity';
export { InventoryCountLine } from './inventory-count-line.entity';
export { InventoryAdjustment } from './inventory-adjustment.entity';
export { InventoryAdjustmentLine } from './inventory-adjustment-line.entity';
export { InventoryCount } from './inventory-count.entity.js';
export { InventoryCountLine } from './inventory-count-line.entity.js';
export { InventoryAdjustment } from './inventory-adjustment.entity.js';
export { InventoryAdjustmentLine } from './inventory-adjustment-line.entity.js';
// Transfers
export { TransferOrder } from './transfer-order.entity';
export { TransferOrderLine } from './transfer-order-line.entity';
export { TransferOrder } from './transfer-order.entity.js';
export { TransferOrderLine } from './transfer-order-line.entity.js';
// Valuation
export { StockValuationLayer } from './stock-valuation-layer.entity';
export { StockValuationLayer } from './stock-valuation-layer.entity.js';

View File

@ -9,7 +9,7 @@ import {
OneToMany,
JoinColumn,
} from 'typeorm';
import { Warehouse } from './warehouse.entity.js';
import { Warehouse } from '../../warehouses/entities/warehouse.entity.js';
import { StockQuant } from './stock-quant.entity.js';
export enum LocationType {

View File

@ -1,79 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
ManyToOne,
OneToMany,
JoinColumn,
} from 'typeorm';
import { Company } from '../../auth/entities/company.entity.js';
import { Location } from './location.entity.js';
/**
* @deprecated This entity is duplicated with warehouses/entities/warehouse.entity.ts
* Both map to inventory.warehouses table but have different column definitions.
*
* PLANNED UNIFICATION:
* - The warehouses module version has more comprehensive fields (address, capacity, settings)
* - This version has Company and Location relations
* - Future: Merge into single entity in warehouses module with all fields and relations
*
* For new code, prefer using: import { Warehouse } from '@modules/warehouses/entities'
*/
@Entity({ schema: 'inventory', name: 'warehouses' })
@Index('idx_warehouses_tenant_id', ['tenantId'])
@Index('idx_warehouses_company_id', ['companyId'])
@Index('idx_warehouses_code_company', ['companyId', 'code'], { unique: true })
export class Warehouse {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
tenantId: string;
@Column({ type: 'uuid', nullable: false, name: 'company_id' })
companyId: string;
@Column({ type: 'varchar', length: 255, nullable: false })
name: string;
@Column({ type: 'varchar', length: 20, nullable: false })
code: string;
@Column({ type: 'uuid', nullable: true, name: 'address_id' })
addressId: string | null;
@Column({ type: 'boolean', default: false, nullable: false, name: 'is_default' })
isDefault: boolean;
@Column({ type: 'boolean', default: true, nullable: false })
active: boolean;
// Relations
@ManyToOne(() => Company)
@JoinColumn({ name: 'company_id' })
company: Company;
@OneToMany(() => Location, (location) => location.warehouse)
locations: Location[];
// Auditoría
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
createdBy: string | null;
@UpdateDateColumn({
name: 'updated_at',
type: 'timestamp',
nullable: true,
})
updatedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
updatedBy: string | null;
}

View File

@ -1,6 +1,6 @@
import { Repository, IsNull } from 'typeorm';
import { AppDataSource } from '../../config/typeorm.js';
import { Warehouse } from './entities/warehouse.entity.js';
import { Warehouse } from '../warehouses/entities/warehouse.entity.js';
import { Location } from './entities/location.entity.js';
import { StockQuant } from './entities/stock-quant.entity.js';
import { NotFoundError, ValidationError, ConflictError } from '../../shared/types/index.js';
@ -9,23 +9,31 @@ import { logger } from '../../shared/utils/logger.js';
// ===== Interfaces =====
export interface CreateWarehouseDto {
companyId: string;
companyId?: string;
name: string;
code: string;
addressId?: string;
description?: string;
addressLine1?: string;
city?: string;
state?: string;
postalCode?: string;
isDefault?: boolean;
}
export interface UpdateWarehouseDto {
name?: string;
addressId?: string | null;
description?: string;
addressLine1?: string;
city?: string;
state?: string;
postalCode?: string;
isDefault?: boolean;
active?: boolean;
isActive?: boolean;
}
export interface WarehouseFilters {
companyId?: string;
active?: boolean;
isActive?: boolean;
page?: number;
limit?: number;
}
@ -52,7 +60,7 @@ class WarehousesService {
filters: WarehouseFilters = {}
): Promise<{ data: WarehouseWithRelations[]; total: number }> {
try {
const { companyId, active, page = 1, limit = 50 } = filters;
const { companyId, isActive, page = 1, limit = 50 } = filters;
const skip = (page - 1) * limit;
const queryBuilder = this.warehouseRepository
@ -64,8 +72,8 @@ class WarehousesService {
queryBuilder.andWhere('warehouse.companyId = :companyId', { companyId });
}
if (active !== undefined) {
queryBuilder.andWhere('warehouse.active = :active', { active });
if (isActive !== undefined) {
queryBuilder.andWhere('warehouse.isActive = :isActive', { isActive });
}
const total = await queryBuilder.getCount();
@ -142,15 +150,20 @@ class WarehousesService {
);
}
const warehouse = this.warehouseRepository.create({
const warehouseData: Partial<Warehouse> = {
tenantId,
companyId: dto.companyId,
name: dto.name,
code: dto.code,
addressId: dto.addressId || null,
description: dto.description,
addressLine1: dto.addressLine1,
city: dto.city,
state: dto.state,
postalCode: dto.postalCode,
isDefault: dto.isDefault || false,
createdBy: userId,
});
};
const warehouse = this.warehouseRepository.create(warehouseData as Warehouse);
await this.warehouseRepository.save(warehouse);
@ -189,12 +202,15 @@ class WarehousesService {
}
if (dto.name !== undefined) existing.name = dto.name;
if (dto.addressId !== undefined) existing.addressId = dto.addressId;
if (dto.description !== undefined) existing.description = dto.description;
if (dto.addressLine1 !== undefined) existing.addressLine1 = dto.addressLine1;
if (dto.city !== undefined) existing.city = dto.city;
if (dto.state !== undefined) existing.state = dto.state;
if (dto.postalCode !== undefined) existing.postalCode = dto.postalCode;
if (dto.isDefault !== undefined) existing.isDefault = dto.isDefault;
if (dto.active !== undefined) existing.active = dto.active;
if (dto.isActive !== undefined) existing.isActive = dto.isActive;
existing.updatedBy = userId;
existing.updatedAt = new Date();
await this.warehouseRepository.save(existing);

View File

@ -46,6 +46,18 @@ export class Product {
@JoinColumn({ name: 'category_id' })
category: ProductCategory;
/**
* Optional link to inventory.products for unified stock management.
* This allows the commerce product to be linked to its inventory counterpart
* for stock tracking, valuation (FIFO/AVERAGE), and warehouse operations.
*
* The inventory product handles: stock levels, lot/serial tracking, valuation layers
* This commerce product handles: pricing, taxes, SAT compliance, commercial data
*/
@Index()
@Column({ name: 'inventory_product_id', type: 'uuid', nullable: true })
inventoryProductId: string | null;
// Identificacion
@Index()
@Column({ type: 'varchar', length: 50 })

View File

@ -1,3 +1,3 @@
export { Warehouse } from './warehouse.entity';
export { WarehouseLocation } from './warehouse-location.entity';
export { WarehouseZone } from './warehouse-zone.entity';
export { Warehouse } from './warehouse.entity.js';
export { WarehouseLocation } from './warehouse-location.entity.js';
export { WarehouseZone } from './warehouse-zone.entity.js';

View File

@ -6,9 +6,25 @@ import {
UpdateDateColumn,
DeleteDateColumn,
Index,
ManyToOne,
OneToMany,
JoinColumn,
} from 'typeorm';
import { Company } from '../../auth/entities/company.entity.js';
/**
* Warehouse Entity (schema: inventory.warehouses)
*
* This is the CANONICAL warehouse entity for the ERP system.
* All warehouse-related imports should use this entity.
*
* Note: The deprecated entity at inventory/entities/warehouse.entity.ts
* has been superseded by this one and should not be used for new code.
*/
@Entity({ name: 'warehouses', schema: 'inventory' })
@Index('idx_warehouses_tenant_id', ['tenantId'])
@Index('idx_warehouses_company_id', ['companyId'])
@Index('idx_warehouses_code_company', ['companyId', 'code'], { unique: true })
export class Warehouse {
@PrimaryGeneratedColumn('uuid')
id: string;
@ -17,6 +33,14 @@ export class Warehouse {
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Index()
@Column({ name: 'company_id', type: 'uuid', nullable: true })
companyId: string | null;
@ManyToOne(() => Company)
@JoinColumn({ name: 'company_id' })
company: Company;
@Index()
@Column({ name: 'branch_id', type: 'uuid', nullable: true })
branchId: string;
@ -90,6 +114,12 @@ export class Warehouse {
autoReorder?: boolean;
};
// Relations (lazy-loaded to avoid circular dependency with inventory module)
// Note: Location entity in inventory module references this entity via warehouse_id FK
// Use: await warehouse.locations to load related locations
@OneToMany('Location', 'warehouse')
locations: Promise<any[]>;
// Estado
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive: boolean;