From 60541027748f7f2f4911147689824090fcce7d2c Mon Sep 17 00:00:00 2001 From: rckrdmrd Date: Sun, 18 Jan 2026 05:13:34 -0600 Subject: [PATCH] [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 --- src/config/typeorm.ts | 6 + src/modules/core/core.controller.ts | 333 +++++++++++ src/modules/core/core.routes.ts | 43 ++ src/modules/core/discount-rules.service.ts | 527 ++++++++++++++++++ .../core/entities/discount-rule.entity.ts | 163 ++++++ src/modules/core/entities/index.ts | 2 + .../core/entities/payment-term.entity.ts | 144 +++++ src/modules/core/index.ts | 2 + src/modules/core/payment-terms.service.ts | 461 +++++++++++++++ src/modules/inventory/entities/index.ts | 33 +- .../inventory/entities/location.entity.ts | 2 +- .../inventory/entities/warehouse.entity.ts | 79 --- src/modules/inventory/warehouses.service.ts | 46 +- .../products/entities/product.entity.ts | 12 + src/modules/warehouses/entities/index.ts | 6 +- .../warehouses/entities/warehouse.entity.ts | 30 + 16 files changed, 1775 insertions(+), 114 deletions(-) create mode 100644 src/modules/core/discount-rules.service.ts create mode 100644 src/modules/core/entities/discount-rule.entity.ts create mode 100644 src/modules/core/entities/payment-term.entity.ts create mode 100644 src/modules/core/payment-terms.service.ts delete mode 100644 src/modules/inventory/entities/warehouse.entity.ts diff --git a/src/config/typeorm.ts b/src/config/typeorm.ts index 2b50f26..27b48e7 100644 --- a/src/config/typeorm.ts +++ b/src/config/typeorm.ts @@ -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, diff --git a/src/modules/core/core.controller.ts b/src/modules/core/core.controller.ts index 79f6c90..a0ad97d 100644 --- a/src/modules/core/core.controller.ts +++ b/src/modules/core/core.controller.ts @@ -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 { @@ -252,6 +386,205 @@ class CoreController { next(error); } } + + // ========== PAYMENT TERMS ========== + async getPaymentTerms(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + try { + const standardTerms = paymentTermsService.getStandardTerms(); + res.json({ success: true, data: standardTerms }); + } catch (error) { + next(error); + } + } + + async initializePaymentTerms(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); diff --git a/src/modules/core/core.routes.ts b/src/modules/core/core.routes.ts index f353f73..f27ab97 100644 --- a/src/modules/core/core.routes.ts +++ b/src/modules/core/core.routes.ts @@ -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; diff --git a/src/modules/core/discount-rules.service.ts b/src/modules/core/discount-rules.service.ts new file mode 100644 index 0000000..9a0bb11 --- /dev/null +++ b/src/modules/core/discount-rules.service.ts @@ -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; + + constructor() { + this.repository = AppDataSource.getRepository(DiscountRule); + } + + /** + * Apply applicable discount rules to a context + */ + async applyDiscounts( + tenantId: string, + context: ApplyDiscountContext + ): Promise { + logger.debug('Applying discounts', { tenantId, context }); + + const applicableRules = await this.findApplicableRules(tenantId, context); + const appliedDiscounts: DiscountResult[] = []; + let runningAmount = context.totalAmount; + let totalDiscount = 0; + + // Sort by priority (lower = higher priority) + const sortedRules = applicableRules.sort((a, b) => a.priority - b.priority); + + for (const rule of sortedRules) { + // Check if rule can be combined with already applied discounts + if (appliedDiscounts.length > 0 && !rule.combinable) { + logger.debug('Skipping non-combinable rule', { ruleCode: rule.code }); + continue; + } + + // Check if previous discounts are non-combinable + const hasNonCombinable = appliedDiscounts.some( + (d) => !sortedRules.find((r) => r.id === d.ruleId)?.combinable + ); + if (hasNonCombinable && !rule.combinable) { + continue; + } + + // Check usage limit + if (rule.usageLimit && rule.usageCount >= rule.usageLimit) { + logger.debug('Rule usage limit reached', { ruleCode: rule.code }); + continue; + } + + // Calculate discount + const discountResult = this.calculateDiscount(rule, runningAmount, context); + + if (discountResult.discountAmount > 0) { + appliedDiscounts.push(discountResult); + totalDiscount += discountResult.discountAmount; + runningAmount = discountResult.finalAmount; + + // Increment usage count + await this.incrementUsageCount(rule.id); + } + } + + return { + appliedDiscounts, + totalDiscount, + originalAmount: context.totalAmount, + finalAmount: context.totalAmount - totalDiscount, + }; + } + + /** + * Calculate discount for a single rule + */ + private calculateDiscount( + rule: DiscountRule, + amount: number, + context: ApplyDiscountContext + ): DiscountResult { + let discountAmount = 0; + let discountPercent = 0; + + switch (rule.discountType) { + case DiscountType.PERCENTAGE: + discountPercent = Number(rule.discountValue); + discountAmount = (amount * discountPercent) / 100; + break; + + case DiscountType.FIXED: + discountAmount = Math.min(Number(rule.discountValue), amount); + discountPercent = (discountAmount / amount) * 100; + break; + + case DiscountType.PRICE_OVERRIDE: + const newPrice = Number(rule.discountValue); + const totalNewAmount = newPrice * context.quantity; + discountAmount = Math.max(0, amount - totalNewAmount); + discountPercent = (discountAmount / amount) * 100; + break; + } + + // Apply max discount cap + if (rule.maxDiscountAmount && discountAmount > Number(rule.maxDiscountAmount)) { + discountAmount = Number(rule.maxDiscountAmount); + discountPercent = (discountAmount / amount) * 100; + } + + return { + ruleId: rule.id, + ruleCode: rule.code, + ruleName: rule.name, + discountType: rule.discountType, + discountAmount: Math.round(discountAmount * 100) / 100, + discountPercent: Math.round(discountPercent * 100) / 100, + originalAmount: amount, + finalAmount: Math.round((amount - discountAmount) * 100) / 100, + }; + } + + /** + * Find all applicable rules for a context + */ + private async findApplicableRules( + tenantId: string, + context: ApplyDiscountContext + ): Promise { + const now = new Date(); + + const queryBuilder = this.repository + .createQueryBuilder('dr') + .where('dr.tenant_id = :tenantId', { tenantId }) + .andWhere('dr.is_active = :isActive', { isActive: true }) + .andWhere('(dr.start_date IS NULL OR dr.start_date <= :now)', { now }) + .andWhere('(dr.end_date IS NULL OR dr.end_date >= :now)', { now }); + + const allRules = await queryBuilder.getMany(); + + // Filter by applies_to and condition + return allRules.filter((rule) => { + // Check applies_to + if (!this.checkAppliesTo(rule, context)) { + return false; + } + + // Check condition + if (!this.checkCondition(rule, context)) { + return false; + } + + return true; + }); + } + + /** + * Check if rule applies to the context + */ + private checkAppliesTo(rule: DiscountRule, context: ApplyDiscountContext): boolean { + switch (rule.appliesTo) { + case DiscountAppliesTo.ALL: + return true; + + case DiscountAppliesTo.PRODUCT: + return rule.appliesToId === context.productId; + + case DiscountAppliesTo.CATEGORY: + return rule.appliesToId === context.categoryId; + + case DiscountAppliesTo.CUSTOMER: + return rule.appliesToId === context.customerId; + + case DiscountAppliesTo.CUSTOMER_GROUP: + return rule.appliesToId === context.customerGroupId; + + default: + return false; + } + } + + /** + * Check if rule condition is met + */ + private checkCondition(rule: DiscountRule, context: ApplyDiscountContext): boolean { + switch (rule.conditionType) { + case DiscountCondition.NONE: + return true; + + case DiscountCondition.MIN_QUANTITY: + return context.quantity >= Number(rule.conditionValue || 0); + + case DiscountCondition.MIN_AMOUNT: + return context.totalAmount >= Number(rule.conditionValue || 0); + + case DiscountCondition.DATE_RANGE: + // Already handled in query + return true; + + case DiscountCondition.FIRST_PURCHASE: + return context.isFirstPurchase === true; + + default: + return true; + } + } + + /** + * Increment usage count for a rule + */ + private async incrementUsageCount(ruleId: string): Promise { + await this.repository.increment({ id: ruleId }, 'usageCount', 1); + } + + /** + * Get all discount rules for a tenant + */ + async findAll(tenantId: string, activeOnly: boolean = false): Promise { + logger.debug('Finding all discount rules', { tenantId, activeOnly }); + + const query = this.repository + .createQueryBuilder('dr') + .where('dr.tenant_id = :tenantId', { tenantId }) + .orderBy('dr.priority', 'ASC') + .addOrderBy('dr.name', 'ASC'); + + if (activeOnly) { + query.andWhere('dr.is_active = :isActive', { isActive: true }); + } + + return query.getMany(); + } + + /** + * Get a specific discount rule by ID + */ + async findById(id: string, tenantId: string): Promise { + logger.debug('Finding discount rule by id', { id, tenantId }); + + const rule = await this.repository.findOne({ + where: { id, tenantId }, + }); + + if (!rule) { + throw new NotFoundError('Regla de descuento no encontrada'); + } + + return rule; + } + + /** + * Get a specific discount rule by code + */ + async findByCode(code: string, tenantId: string): Promise { + logger.debug('Finding discount rule by code', { code, tenantId }); + + return this.repository.findOne({ + where: { code, tenantId }, + }); + } + + /** + * Create a new discount rule + */ + async create( + dto: CreateDiscountRuleDto, + tenantId: string, + userId?: string + ): Promise { + logger.debug('Creating discount rule', { dto, tenantId }); + + // Check for existing + const existing = await this.findByCode(dto.code, tenantId); + if (existing) { + throw new ConflictError(`Ya existe una regla de descuento con código ${dto.code}`); + } + + // Normalize inputs + const discountTypeRaw = dto.discount_type ?? dto.discountType ?? 'percentage'; + const discountType = discountTypeRaw as DiscountType; + const discountValue = dto.discount_value ?? dto.discountValue; + const maxDiscountAmount = dto.max_discount_amount ?? dto.maxDiscountAmount ?? null; + const appliesToRaw = dto.applies_to ?? dto.appliesTo ?? 'all'; + const appliesTo = appliesToRaw as DiscountAppliesTo; + const appliesToId = dto.applies_to_id ?? dto.appliesToId ?? null; + const conditionTypeRaw = dto.condition_type ?? dto.conditionType ?? 'none'; + const conditionType = conditionTypeRaw as DiscountCondition; + const conditionValue = dto.condition_value ?? dto.conditionValue ?? null; + const startDate = dto.start_date ?? dto.startDate ?? null; + const endDate = dto.end_date ?? dto.endDate ?? null; + const usageLimit = dto.usage_limit ?? dto.usageLimit ?? null; + + if (discountValue === undefined) { + throw new ValidationError('discount_value es requerido'); + } + + const rule = this.repository.create({ + tenantId, + code: dto.code, + name: dto.name, + description: dto.description || null, + discountType, + discountValue, + maxDiscountAmount, + appliesTo, + appliesToId, + conditionType, + conditionValue, + startDate: startDate ? new Date(startDate) : null, + endDate: endDate ? new Date(endDate) : null, + priority: dto.priority ?? 10, + combinable: dto.combinable ?? true, + usageLimit, + createdBy: userId || null, + }); + + const saved = await this.repository.save(rule); + + logger.info('Discount rule created', { id: saved.id, code: dto.code, tenantId }); + + return saved; + } + + /** + * Update a discount rule + */ + async update( + id: string, + dto: UpdateDiscountRuleDto, + tenantId: string, + userId?: string + ): Promise { + logger.debug('Updating discount rule', { id, dto, tenantId }); + + const existing = await this.findById(id, tenantId); + + // Normalize inputs + const discountTypeRaw = dto.discount_type ?? dto.discountType; + const discountValue = dto.discount_value ?? dto.discountValue; + const maxDiscountAmount = dto.max_discount_amount ?? dto.maxDiscountAmount; + const appliesToRaw = dto.applies_to ?? dto.appliesTo; + const appliesToId = dto.applies_to_id ?? dto.appliesToId; + const conditionTypeRaw = dto.condition_type ?? dto.conditionType; + const conditionValue = dto.condition_value ?? dto.conditionValue; + const startDate = dto.start_date ?? dto.startDate; + const endDate = dto.end_date ?? dto.endDate; + const usageLimit = dto.usage_limit ?? dto.usageLimit; + const isActive = dto.is_active ?? dto.isActive; + + if (dto.name !== undefined) existing.name = dto.name; + if (dto.description !== undefined) existing.description = dto.description; + if (discountTypeRaw !== undefined) existing.discountType = discountTypeRaw as DiscountType; + if (discountValue !== undefined) existing.discountValue = discountValue; + if (maxDiscountAmount !== undefined) existing.maxDiscountAmount = maxDiscountAmount; + if (appliesToRaw !== undefined) existing.appliesTo = appliesToRaw as DiscountAppliesTo; + if (appliesToId !== undefined) existing.appliesToId = appliesToId; + if (conditionTypeRaw !== undefined) existing.conditionType = conditionTypeRaw as DiscountCondition; + if (conditionValue !== undefined) existing.conditionValue = conditionValue; + if (startDate !== undefined) existing.startDate = startDate ? new Date(startDate) : null; + if (endDate !== undefined) existing.endDate = endDate ? new Date(endDate) : null; + if (dto.priority !== undefined) existing.priority = dto.priority; + if (dto.combinable !== undefined) existing.combinable = dto.combinable; + if (usageLimit !== undefined) existing.usageLimit = usageLimit; + if (isActive !== undefined) existing.isActive = isActive; + + existing.updatedBy = userId || null; + + const updated = await this.repository.save(existing); + + logger.info('Discount rule updated', { id, tenantId }); + + return updated; + } + + /** + * Soft delete a discount rule + */ + async delete(id: string, tenantId: string, userId?: string): Promise { + logger.debug('Deleting discount rule', { id, tenantId }); + + const existing = await this.findById(id, tenantId); + + existing.deletedAt = new Date(); + existing.deletedBy = userId || null; + + await this.repository.save(existing); + + logger.info('Discount rule deleted', { id, tenantId }); + } + + /** + * Reset usage count for a rule + */ + async resetUsageCount(id: string, tenantId: string): Promise { + logger.debug('Resetting usage count', { id, tenantId }); + + const rule = await this.findById(id, tenantId); + rule.usageCount = 0; + + return this.repository.save(rule); + } + + /** + * Find rules by product + */ + async findByProduct(productId: string, tenantId: string): Promise { + return this.repository.find({ + where: [ + { tenantId, appliesTo: DiscountAppliesTo.PRODUCT, appliesToId: productId, isActive: true }, + { tenantId, appliesTo: DiscountAppliesTo.ALL, isActive: true }, + ], + order: { priority: 'ASC' }, + }); + } + + /** + * Find rules by customer + */ + async findByCustomer(customerId: string, tenantId: string): Promise { + return this.repository.find({ + where: [ + { tenantId, appliesTo: DiscountAppliesTo.CUSTOMER, appliesToId: customerId, isActive: true }, + { tenantId, appliesTo: DiscountAppliesTo.ALL, isActive: true }, + ], + order: { priority: 'ASC' }, + }); + } +} + +export const discountRulesService = new DiscountRulesService(); diff --git a/src/modules/core/entities/discount-rule.entity.ts b/src/modules/core/entities/discount-rule.entity.ts new file mode 100644 index 0000000..1454a4a --- /dev/null +++ b/src/modules/core/entities/discount-rule.entity.ts @@ -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; +} diff --git a/src/modules/core/entities/index.ts b/src/modules/core/entities/index.ts index fda5d7a..7abd07e 100644 --- a/src/modules/core/entities/index.ts +++ b/src/modules/core/entities/index.ts @@ -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'; diff --git a/src/modules/core/entities/payment-term.entity.ts b/src/modules/core/entities/payment-term.entity.ts new file mode 100644 index 0000000..38c3e17 --- /dev/null +++ b/src/modules/core/entities/payment-term.entity.ts @@ -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; +} diff --git a/src/modules/core/index.ts b/src/modules/core/index.ts index 10a620d..01167f3 100644 --- a/src/modules/core/index.ts +++ b/src/modules/core/index.ts @@ -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'; diff --git a/src/modules/core/payment-terms.service.ts b/src/modules/core/payment-terms.service.ts new file mode 100644 index 0000000..1d22b46 --- /dev/null +++ b/src/modules/core/payment-terms.service.ts @@ -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; + private lineRepository: Repository; + + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); diff --git a/src/modules/inventory/entities/index.ts b/src/modules/inventory/entities/index.ts index 2e7db5c..6347284 100644 --- a/src/modules/inventory/entities/index.ts +++ b/src/modules/inventory/entities/index.ts @@ -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'; diff --git a/src/modules/inventory/entities/location.entity.ts b/src/modules/inventory/entities/location.entity.ts index 9622b72..28dcc57 100644 --- a/src/modules/inventory/entities/location.entity.ts +++ b/src/modules/inventory/entities/location.entity.ts @@ -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 { diff --git a/src/modules/inventory/entities/warehouse.entity.ts b/src/modules/inventory/entities/warehouse.entity.ts deleted file mode 100644 index 15cc09a..0000000 --- a/src/modules/inventory/entities/warehouse.entity.ts +++ /dev/null @@ -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; -} diff --git a/src/modules/inventory/warehouses.service.ts b/src/modules/inventory/warehouses.service.ts index f000c57..73e0a2c 100644 --- a/src/modules/inventory/warehouses.service.ts +++ b/src/modules/inventory/warehouses.service.ts @@ -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 = { 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); diff --git a/src/modules/products/entities/product.entity.ts b/src/modules/products/entities/product.entity.ts index b124126..d665b2c 100644 --- a/src/modules/products/entities/product.entity.ts +++ b/src/modules/products/entities/product.entity.ts @@ -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 }) diff --git a/src/modules/warehouses/entities/index.ts b/src/modules/warehouses/entities/index.ts index fb6b6e3..9220865 100644 --- a/src/modules/warehouses/entities/index.ts +++ b/src/modules/warehouses/entities/index.ts @@ -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'; diff --git a/src/modules/warehouses/entities/warehouse.entity.ts b/src/modules/warehouses/entities/warehouse.entity.ts index dc8c6f1..007bbd9 100644 --- a/src/modules/warehouses/entities/warehouse.entity.ts +++ b/src/modules/warehouses/entities/warehouse.entity.ts @@ -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; + // Estado @Column({ name: 'is_active', type: 'boolean', default: true }) isActive: boolean;