import { Response, NextFunction } from 'express'; import { z } from 'zod'; import { currenciesService, CreateCurrencyDto, UpdateCurrencyDto } from './currencies.service.js'; 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'; // Schemas const createCurrencySchema = z.object({ code: z.string().length(3, 'El código debe tener 3 caracteres').toUpperCase(), name: z.string().min(1, 'El nombre es requerido').max(100), symbol: z.string().min(1).max(10), decimal_places: z.number().int().min(0).max(6).optional(), decimals: z.number().int().min(0).max(6).optional(), // Accept camelCase }).refine((data) => data.decimal_places !== undefined || data.decimals !== undefined, { message: 'decimal_places or decimals is required', }); const updateCurrencySchema = z.object({ name: z.string().min(1).max(100).optional(), symbol: z.string().min(1).max(10).optional(), decimal_places: z.number().int().min(0).max(6).optional(), decimals: z.number().int().min(0).max(6).optional(), // Accept camelCase active: z.boolean().optional(), }); const createUomSchema = z.object({ name: z.string().min(1, 'El nombre es requerido').max(100), code: z.string().min(1).max(20), category_id: z.string().uuid().optional(), categoryId: z.string().uuid().optional(), // Accept camelCase uom_type: z.enum(['reference', 'bigger', 'smaller']).optional(), uomType: z.enum(['reference', 'bigger', 'smaller']).optional(), // Accept camelCase ratio: z.number().positive().default(1), }).refine((data) => data.category_id !== undefined || data.categoryId !== undefined, { message: 'category_id or categoryId is required', }); const updateUomSchema = z.object({ name: z.string().min(1).max(100).optional(), ratio: z.number().positive().optional(), active: z.boolean().optional(), }); const createCategorySchema = z.object({ name: z.string().min(1, 'El nombre es requerido').max(100), code: z.string().min(1).max(50), parent_id: z.string().uuid().optional(), parentId: z.string().uuid().optional(), // Accept camelCase }); const updateCategorySchema = z.object({ name: z.string().min(1).max(100).optional(), parent_id: z.string().uuid().optional().nullable(), parentId: z.string().uuid().optional().nullable(), // Accept camelCase 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 { try { const activeOnly = req.query.active === 'true'; const currencies = await currenciesService.findAll(activeOnly); res.json({ success: true, data: currencies }); } catch (error) { next(error); } } async getCurrency(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { try { const currency = await currenciesService.findById(req.params.id); res.json({ success: true, data: currency }); } catch (error) { next(error); } } async createCurrency(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { try { const parseResult = createCurrencySchema.safeParse(req.body); if (!parseResult.success) { throw new ValidationError('Datos de moneda inválidos', parseResult.error.errors); } const dto: CreateCurrencyDto = parseResult.data; const currency = await currenciesService.create(dto); res.status(201).json({ success: true, data: currency, message: 'Moneda creada exitosamente' }); } catch (error) { next(error); } } async updateCurrency(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { try { const parseResult = updateCurrencySchema.safeParse(req.body); if (!parseResult.success) { throw new ValidationError('Datos de moneda inválidos', parseResult.error.errors); } const dto: UpdateCurrencyDto = parseResult.data; const currency = await currenciesService.update(req.params.id, dto); res.json({ success: true, data: currency, message: 'Moneda actualizada exitosamente' }); } catch (error) { next(error); } } // ========== COUNTRIES ========== async getCountries(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { try { const countries = await countriesService.findAll(); res.json({ success: true, data: countries }); } catch (error) { next(error); } } async getCountry(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { try { const country = await countriesService.findById(req.params.id); res.json({ success: true, data: country }); } catch (error) { next(error); } } // ========== UOM CATEGORIES ========== async getUomCategories(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { try { const activeOnly = req.query.active === 'true'; const categories = await uomService.findAllCategories(activeOnly); res.json({ success: true, data: categories }); } catch (error) { next(error); } } async getUomCategory(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { try { const category = await uomService.findCategoryById(req.params.id); res.json({ success: true, data: category }); } catch (error) { next(error); } } // ========== UOM ========== async getUoms(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { try { const activeOnly = req.query.active === 'true'; const categoryId = req.query.category_id as string | undefined; const uoms = await uomService.findAll(categoryId, activeOnly); res.json({ success: true, data: uoms }); } catch (error) { next(error); } } async getUom(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { try { const uom = await uomService.findById(req.params.id); res.json({ success: true, data: uom }); } catch (error) { next(error); } } async createUom(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { try { const parseResult = createUomSchema.safeParse(req.body); if (!parseResult.success) { throw new ValidationError('Datos de UdM inválidos', parseResult.error.errors); } const dto: CreateUomDto = parseResult.data; const uom = await uomService.create(dto); res.status(201).json({ success: true, data: uom, message: 'Unidad de medida creada exitosamente' }); } catch (error) { next(error); } } async updateUom(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { try { const parseResult = updateUomSchema.safeParse(req.body); if (!parseResult.success) { throw new ValidationError('Datos de UdM inválidos', parseResult.error.errors); } const dto: UpdateUomDto = parseResult.data; const uom = await uomService.update(req.params.id, dto); res.json({ success: true, data: uom, message: 'Unidad de medida actualizada exitosamente' }); } catch (error) { next(error); } } // ========== PRODUCT CATEGORIES ========== async getProductCategories(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { try { const activeOnly = req.query.active === 'true'; const parentId = req.query.parent_id as string | undefined; const categories = await productCategoriesService.findAll(req.tenantId!, parentId, activeOnly); res.json({ success: true, data: categories }); } catch (error) { next(error); } } async getProductCategory(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { try { const category = await productCategoriesService.findById(req.params.id, req.tenantId!); res.json({ success: true, data: category }); } catch (error) { next(error); } } async createProductCategory(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { try { const parseResult = createCategorySchema.safeParse(req.body); if (!parseResult.success) { throw new ValidationError('Datos de categoría inválidos', parseResult.error.errors); } const dto: CreateProductCategoryDto = parseResult.data; const category = await productCategoriesService.create(dto, req.tenantId!, req.user!.userId); res.status(201).json({ success: true, data: category, message: 'Categoría creada exitosamente' }); } catch (error) { next(error); } } async updateProductCategory(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { try { const parseResult = updateCategorySchema.safeParse(req.body); if (!parseResult.success) { throw new ValidationError('Datos de categoría inválidos', parseResult.error.errors); } const dto: UpdateProductCategoryDto = parseResult.data; const category = await productCategoriesService.update(req.params.id, dto, req.tenantId!, req.user!.userId); res.json({ success: true, data: category, message: 'Categoría actualizada exitosamente' }); } catch (error) { next(error); } } async deleteProductCategory(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { try { await productCategoriesService.delete(req.params.id, req.tenantId!); res.json({ success: true, message: 'Categoría eliminada exitosamente' }); } catch (error) { 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();