import { query, queryOne } from '../../config/database.js'; import { NotFoundError, ConflictError } from '../../shared/errors/index.js'; export interface Tax { id: string; tenant_id: string; company_id: string; company_name?: string; name: string; code: string; tax_type: 'sales' | 'purchase' | 'all'; amount: number; included_in_price: boolean; active: boolean; created_at: Date; } export interface CreateTaxDto { company_id: string; name: string; code: string; tax_type: 'sales' | 'purchase' | 'all'; amount: number; included_in_price?: boolean; } export interface UpdateTaxDto { name?: string; code?: string; tax_type?: 'sales' | 'purchase' | 'all'; amount?: number; included_in_price?: boolean; active?: boolean; } export interface TaxFilters { company_id?: string; tax_type?: string; active?: boolean; search?: string; page?: number; limit?: number; } class TaxesService { async findAll(tenantId: string, filters: TaxFilters = {}): Promise<{ data: Tax[]; total: number }> { const { company_id, tax_type, active, search, page = 1, limit = 20 } = filters; const offset = (page - 1) * limit; let whereClause = 'WHERE t.tenant_id = $1'; const params: any[] = [tenantId]; let paramIndex = 2; if (company_id) { whereClause += ` AND t.company_id = $${paramIndex++}`; params.push(company_id); } if (tax_type) { whereClause += ` AND t.tax_type = $${paramIndex++}`; params.push(tax_type); } if (active !== undefined) { whereClause += ` AND t.active = $${paramIndex++}`; params.push(active); } if (search) { whereClause += ` AND (t.name ILIKE $${paramIndex} OR t.code ILIKE $${paramIndex})`; params.push(`%${search}%`); paramIndex++; } const countResult = await queryOne<{ count: string }>( `SELECT COUNT(*) as count FROM financial.taxes t ${whereClause}`, params ); params.push(limit, offset); const data = await query( `SELECT t.*, c.name as company_name FROM financial.taxes t LEFT JOIN auth.companies c ON t.company_id = c.id ${whereClause} ORDER BY t.name LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, params ); return { data, total: parseInt(countResult?.count || '0', 10), }; } async findById(id: string, tenantId: string): Promise { const tax = await queryOne( `SELECT t.*, c.name as company_name FROM financial.taxes t LEFT JOIN auth.companies c ON t.company_id = c.id WHERE t.id = $1 AND t.tenant_id = $2`, [id, tenantId] ); if (!tax) { throw new NotFoundError('Impuesto no encontrado'); } return tax; } async create(dto: CreateTaxDto, tenantId: string, userId: string): Promise { // Check unique code const existing = await queryOne( `SELECT id FROM financial.taxes WHERE tenant_id = $1 AND code = $2`, [tenantId, dto.code] ); if (existing) { throw new ConflictError('Ya existe un impuesto con ese código'); } const tax = await queryOne( `INSERT INTO financial.taxes ( tenant_id, company_id, name, code, tax_type, amount, included_in_price, created_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`, [ tenantId, dto.company_id, dto.name, dto.code, dto.tax_type, dto.amount, dto.included_in_price ?? false, userId ] ); return tax!; } async update(id: string, dto: UpdateTaxDto, tenantId: string, userId: string): Promise { const existing = await this.findById(id, tenantId); const updateFields: string[] = []; const values: any[] = []; let paramIndex = 1; if (dto.name !== undefined) { updateFields.push(`name = $${paramIndex++}`); values.push(dto.name); } if (dto.code !== undefined) { // Check unique code const existingCode = await queryOne( `SELECT id FROM financial.taxes WHERE tenant_id = $1 AND code = $2 AND id != $3`, [tenantId, dto.code, id] ); if (existingCode) { throw new ConflictError('Ya existe un impuesto con ese código'); } updateFields.push(`code = $${paramIndex++}`); values.push(dto.code); } if (dto.tax_type !== undefined) { updateFields.push(`tax_type = $${paramIndex++}`); values.push(dto.tax_type); } if (dto.amount !== undefined) { updateFields.push(`amount = $${paramIndex++}`); values.push(dto.amount); } if (dto.included_in_price !== undefined) { updateFields.push(`included_in_price = $${paramIndex++}`); values.push(dto.included_in_price); } if (dto.active !== undefined) { updateFields.push(`active = $${paramIndex++}`); values.push(dto.active); } if (updateFields.length === 0) { return existing; } updateFields.push(`updated_by = $${paramIndex++}`); values.push(userId); updateFields.push(`updated_at = CURRENT_TIMESTAMP`); values.push(id, tenantId); await query( `UPDATE financial.taxes SET ${updateFields.join(', ')} WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`, values ); return this.findById(id, tenantId); } async delete(id: string, tenantId: string): Promise { await this.findById(id, tenantId); // Check if tax is used in any invoice lines const usageCheck = await queryOne<{ count: string }>( `SELECT COUNT(*) as count FROM financial.invoice_lines WHERE $1 = ANY(tax_ids)`, [id] ); if (parseInt(usageCheck?.count || '0') > 0) { throw new ConflictError('No se puede eliminar: el impuesto está siendo usado en facturas'); } await query( `DELETE FROM financial.taxes WHERE id = $1 AND tenant_id = $2`, [id, tenantId] ); } /** * Calcula impuestos para una linea de documento * Sigue la logica de Odoo para calculos de IVA */ async calculateTaxes( lineData: TaxCalculationInput, tenantId: string, transactionType: 'sales' | 'purchase' = 'sales' ): Promise { // Validar inputs if (lineData.quantity <= 0 || lineData.priceUnit < 0) { return { amountUntaxed: 0, amountTax: 0, amountTotal: 0, taxBreakdown: [], }; } // Calcular subtotal antes de impuestos const subtotal = lineData.quantity * lineData.priceUnit; const discountAmount = subtotal * (lineData.discount || 0) / 100; const amountUntaxed = subtotal - discountAmount; // Si no hay impuestos, retornar solo el monto sin impuestos if (!lineData.taxIds || lineData.taxIds.length === 0) { return { amountUntaxed, amountTax: 0, amountTotal: amountUntaxed, taxBreakdown: [], }; } // Obtener impuestos de la BD const taxResults = await query( `SELECT * FROM financial.taxes WHERE id = ANY($1) AND tenant_id = $2 AND active = true AND (tax_type = $3 OR tax_type = 'all')`, [lineData.taxIds, tenantId, transactionType] ); if (taxResults.length === 0) { return { amountUntaxed, amountTax: 0, amountTotal: amountUntaxed, taxBreakdown: [], }; } // Calcular impuestos const taxBreakdown: TaxBreakdownItem[] = []; let totalTax = 0; for (const tax of taxResults) { let taxBase = amountUntaxed; let taxAmount: number; if (tax.included_in_price) { // Precio incluye impuesto (IVA incluido) // Base = Precio / (1 + tasa) // Impuesto = Precio - Base taxBase = amountUntaxed / (1 + tax.amount / 100); taxAmount = amountUntaxed - taxBase; } else { // Precio sin impuesto (IVA añadido) // Impuesto = Base * tasa taxAmount = amountUntaxed * tax.amount / 100; } taxBreakdown.push({ taxId: tax.id, taxName: tax.name, taxCode: tax.code, taxRate: tax.amount, includedInPrice: tax.included_in_price, base: Math.round(taxBase * 100) / 100, taxAmount: Math.round(taxAmount * 100) / 100, }); totalTax += taxAmount; } // Redondear a 2 decimales const finalAmountTax = Math.round(totalTax * 100) / 100; const finalAmountUntaxed = Math.round(amountUntaxed * 100) / 100; const finalAmountTotal = Math.round((amountUntaxed + finalAmountTax) * 100) / 100; return { amountUntaxed: finalAmountUntaxed, amountTax: finalAmountTax, amountTotal: finalAmountTotal, taxBreakdown, }; } /** * Calcula impuestos para multiples lineas (ej: para totales de documento) */ async calculateDocumentTaxes( lines: TaxCalculationInput[], tenantId: string, transactionType: 'sales' | 'purchase' = 'sales' ): Promise { let totalUntaxed = 0; let totalTax = 0; const allBreakdown: TaxBreakdownItem[] = []; for (const line of lines) { const result = await this.calculateTaxes(line, tenantId, transactionType); totalUntaxed += result.amountUntaxed; totalTax += result.amountTax; allBreakdown.push(...result.taxBreakdown); } // Consolidar breakdown por impuesto const consolidatedBreakdown = new Map(); for (const item of allBreakdown) { const existing = consolidatedBreakdown.get(item.taxId); if (existing) { existing.base += item.base; existing.taxAmount += item.taxAmount; } else { consolidatedBreakdown.set(item.taxId, { ...item }); } } return { amountUntaxed: Math.round(totalUntaxed * 100) / 100, amountTax: Math.round(totalTax * 100) / 100, amountTotal: Math.round((totalUntaxed + totalTax) * 100) / 100, taxBreakdown: Array.from(consolidatedBreakdown.values()), }; } } // Interfaces para calculo de impuestos export interface TaxCalculationInput { quantity: number; priceUnit: number; discount: number; taxIds: string[]; } export interface TaxBreakdownItem { taxId: string; taxName: string; taxCode: string; taxRate: number; includedInPrice: boolean; base: number; taxAmount: number; } export interface TaxCalculationResult { amountUntaxed: number; amountTax: number; amountTotal: number; taxBreakdown: TaxBreakdownItem[]; } export const taxesService = new TaxesService();