import { query, queryOne, getClient } from '../../config/database.js'; import { NotFoundError, ValidationError } from '../../shared/errors/index.js'; import { taxesService } from './taxes.service.js'; import { glPostingService, InvoiceForPosting } from './gl-posting.service.js'; import { sequencesService, SEQUENCE_CODES } from '../core/sequences.service.js'; import { logger } from '../../shared/utils/logger.js'; export interface InvoiceLine { id: string; invoice_id: string; product_id?: string; product_name?: string; description: string; quantity: number; uom_id?: string; uom_name?: string; price_unit: number; tax_ids: string[]; amount_untaxed: number; amount_tax: number; amount_total: number; account_id?: string; account_name?: string; } export interface Invoice { id: string; tenant_id: string; company_id: string; company_name?: string; partner_id: string; partner_name?: string; invoice_type: 'customer' | 'supplier'; number?: string; ref?: string; invoice_date: Date; due_date?: Date; currency_id: string; currency_code?: string; amount_untaxed: number; amount_tax: number; amount_total: number; amount_paid: number; amount_residual: number; status: 'draft' | 'open' | 'paid' | 'cancelled'; payment_term_id?: string; journal_id?: string; journal_entry_id?: string; notes?: string; lines?: InvoiceLine[]; created_at: Date; validated_at?: Date; } export interface CreateInvoiceDto { company_id: string; partner_id: string; invoice_type: 'customer' | 'supplier'; ref?: string; invoice_date?: string; due_date?: string; currency_id: string; payment_term_id?: string; journal_id?: string; notes?: string; } export interface UpdateInvoiceDto { partner_id?: string; ref?: string | null; invoice_date?: string; due_date?: string | null; currency_id?: string; payment_term_id?: string | null; journal_id?: string | null; notes?: string | null; } export interface CreateInvoiceLineDto { product_id?: string; description: string; quantity: number; uom_id?: string; price_unit: number; tax_ids?: string[]; account_id?: string; } export interface UpdateInvoiceLineDto { product_id?: string | null; description?: string; quantity?: number; uom_id?: string | null; price_unit?: number; tax_ids?: string[]; account_id?: string | null; } export interface InvoiceFilters { company_id?: string; partner_id?: string; invoice_type?: string; status?: string; date_from?: string; date_to?: string; search?: string; page?: number; limit?: number; } class InvoicesService { async findAll(tenantId: string, filters: InvoiceFilters = {}): Promise<{ data: Invoice[]; total: number }> { const { company_id, partner_id, invoice_type, status, date_from, date_to, search, page = 1, limit = 20 } = filters; const offset = (page - 1) * limit; let whereClause = 'WHERE i.tenant_id = $1'; const params: any[] = [tenantId]; let paramIndex = 2; if (company_id) { whereClause += ` AND i.company_id = $${paramIndex++}`; params.push(company_id); } if (partner_id) { whereClause += ` AND i.partner_id = $${paramIndex++}`; params.push(partner_id); } if (invoice_type) { whereClause += ` AND i.invoice_type = $${paramIndex++}`; params.push(invoice_type); } if (status) { whereClause += ` AND i.status = $${paramIndex++}`; params.push(status); } if (date_from) { whereClause += ` AND i.invoice_date >= $${paramIndex++}`; params.push(date_from); } if (date_to) { whereClause += ` AND i.invoice_date <= $${paramIndex++}`; params.push(date_to); } if (search) { whereClause += ` AND (i.number ILIKE $${paramIndex} OR i.ref ILIKE $${paramIndex} OR p.name ILIKE $${paramIndex})`; params.push(`%${search}%`); paramIndex++; } const countResult = await queryOne<{ count: string }>( `SELECT COUNT(*) as count FROM financial.invoices i LEFT JOIN core.partners p ON i.partner_id = p.id ${whereClause}`, params ); params.push(limit, offset); const data = await query( `SELECT i.*, c.name as company_name, p.name as partner_name, cu.code as currency_code FROM financial.invoices i LEFT JOIN auth.companies c ON i.company_id = c.id LEFT JOIN core.partners p ON i.partner_id = p.id LEFT JOIN core.currencies cu ON i.currency_id = cu.id ${whereClause} ORDER BY i.invoice_date DESC, i.created_at DESC LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, params ); return { data, total: parseInt(countResult?.count || '0', 10), }; } async findById(id: string, tenantId: string): Promise { const invoice = await queryOne( `SELECT i.*, c.name as company_name, p.name as partner_name, cu.code as currency_code FROM financial.invoices i LEFT JOIN auth.companies c ON i.company_id = c.id LEFT JOIN core.partners p ON i.partner_id = p.id LEFT JOIN core.currencies cu ON i.currency_id = cu.id WHERE i.id = $1 AND i.tenant_id = $2`, [id, tenantId] ); if (!invoice) { throw new NotFoundError('Factura no encontrada'); } // Get lines const lines = await query( `SELECT il.*, pr.name as product_name, um.name as uom_name, a.name as account_name FROM financial.invoice_lines il LEFT JOIN inventory.products pr ON il.product_id = pr.id LEFT JOIN core.uom um ON il.uom_id = um.id LEFT JOIN financial.accounts a ON il.account_id = a.id WHERE il.invoice_id = $1 ORDER BY il.created_at`, [id] ); invoice.lines = lines; return invoice; } async create(dto: CreateInvoiceDto, tenantId: string, userId: string): Promise { const invoiceDate = dto.invoice_date || new Date().toISOString().split('T')[0]; const invoice = await queryOne( `INSERT INTO financial.invoices ( tenant_id, company_id, partner_id, invoice_type, ref, invoice_date, due_date, currency_id, payment_term_id, journal_id, notes, amount_untaxed, amount_tax, amount_total, amount_paid, amount_residual, created_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 0, 0, 0, 0, 0, $12) RETURNING *`, [ tenantId, dto.company_id, dto.partner_id, dto.invoice_type, dto.ref, invoiceDate, dto.due_date, dto.currency_id, dto.payment_term_id, dto.journal_id, dto.notes, userId ] ); return invoice!; } async update(id: string, dto: UpdateInvoiceDto, tenantId: string, userId: string): Promise { const existing = await this.findById(id, tenantId); if (existing.status !== 'draft') { throw new ValidationError('Solo se pueden editar facturas en estado borrador'); } const updateFields: string[] = []; const values: any[] = []; let paramIndex = 1; if (dto.partner_id !== undefined) { updateFields.push(`partner_id = $${paramIndex++}`); values.push(dto.partner_id); } if (dto.ref !== undefined) { updateFields.push(`ref = $${paramIndex++}`); values.push(dto.ref); } if (dto.invoice_date !== undefined) { updateFields.push(`invoice_date = $${paramIndex++}`); values.push(dto.invoice_date); } if (dto.due_date !== undefined) { updateFields.push(`due_date = $${paramIndex++}`); values.push(dto.due_date); } if (dto.currency_id !== undefined) { updateFields.push(`currency_id = $${paramIndex++}`); values.push(dto.currency_id); } if (dto.payment_term_id !== undefined) { updateFields.push(`payment_term_id = $${paramIndex++}`); values.push(dto.payment_term_id); } if (dto.journal_id !== undefined) { updateFields.push(`journal_id = $${paramIndex++}`); values.push(dto.journal_id); } if (dto.notes !== undefined) { updateFields.push(`notes = $${paramIndex++}`); values.push(dto.notes); } 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.invoices SET ${updateFields.join(', ')} WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`, values ); return this.findById(id, tenantId); } async delete(id: string, tenantId: string): Promise { const existing = await this.findById(id, tenantId); if (existing.status !== 'draft') { throw new ValidationError('Solo se pueden eliminar facturas en estado borrador'); } await query( `DELETE FROM financial.invoices WHERE id = $1 AND tenant_id = $2`, [id, tenantId] ); } async addLine(invoiceId: string, dto: CreateInvoiceLineDto, tenantId: string): Promise { const invoice = await this.findById(invoiceId, tenantId); if (invoice.status !== 'draft') { throw new ValidationError('Solo se pueden agregar líneas a facturas en estado borrador'); } // Calculate amounts with taxes using taxesService // Determine transaction type based on invoice type const transactionType = invoice.invoice_type === 'customer' ? 'sales' : 'purchase'; const taxResult = await taxesService.calculateTaxes( { quantity: dto.quantity, priceUnit: dto.price_unit, discount: 0, // Invoices don't have line discounts by default taxIds: dto.tax_ids || [], }, tenantId, transactionType ); const amountUntaxed = taxResult.amountUntaxed; const amountTax = taxResult.amountTax; const amountTotal = taxResult.amountTotal; const line = await queryOne( `INSERT INTO financial.invoice_lines ( invoice_id, tenant_id, product_id, description, quantity, uom_id, price_unit, tax_ids, amount_untaxed, amount_tax, amount_total, account_id ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *`, [ invoiceId, tenantId, dto.product_id, dto.description, dto.quantity, dto.uom_id, dto.price_unit, dto.tax_ids || [], amountUntaxed, amountTax, amountTotal, dto.account_id ] ); // Update invoice totals await this.updateTotals(invoiceId); return line!; } async updateLine(invoiceId: string, lineId: string, dto: UpdateInvoiceLineDto, tenantId: string): Promise { const invoice = await this.findById(invoiceId, tenantId); if (invoice.status !== 'draft') { throw new ValidationError('Solo se pueden editar líneas de facturas en estado borrador'); } const existingLine = invoice.lines?.find(l => l.id === lineId); if (!existingLine) { throw new NotFoundError('Línea de factura no encontrada'); } const updateFields: string[] = []; const values: any[] = []; let paramIndex = 1; const quantity = dto.quantity ?? existingLine.quantity; const priceUnit = dto.price_unit ?? existingLine.price_unit; if (dto.product_id !== undefined) { updateFields.push(`product_id = $${paramIndex++}`); values.push(dto.product_id); } if (dto.description !== undefined) { updateFields.push(`description = $${paramIndex++}`); values.push(dto.description); } if (dto.quantity !== undefined) { updateFields.push(`quantity = $${paramIndex++}`); values.push(dto.quantity); } if (dto.uom_id !== undefined) { updateFields.push(`uom_id = $${paramIndex++}`); values.push(dto.uom_id); } if (dto.price_unit !== undefined) { updateFields.push(`price_unit = $${paramIndex++}`); values.push(dto.price_unit); } if (dto.tax_ids !== undefined) { updateFields.push(`tax_ids = $${paramIndex++}`); values.push(dto.tax_ids); } if (dto.account_id !== undefined) { updateFields.push(`account_id = $${paramIndex++}`); values.push(dto.account_id); } // Recalculate amounts using taxesService const taxIds = dto.tax_ids ?? existingLine.tax_ids ?? []; const transactionType = invoice.invoice_type === 'customer' ? 'sales' : 'purchase'; const taxResult = await taxesService.calculateTaxes( { quantity, priceUnit, discount: 0, taxIds, }, tenantId, transactionType ); const amountUntaxed = taxResult.amountUntaxed; const amountTax = taxResult.amountTax; const amountTotal = taxResult.amountTotal; updateFields.push(`amount_untaxed = $${paramIndex++}`); values.push(amountUntaxed); updateFields.push(`amount_tax = $${paramIndex++}`); values.push(amountTax); updateFields.push(`amount_total = $${paramIndex++}`); values.push(amountTotal); updateFields.push(`updated_at = CURRENT_TIMESTAMP`); values.push(lineId, invoiceId); await query( `UPDATE financial.invoice_lines SET ${updateFields.join(', ')} WHERE id = $${paramIndex++} AND invoice_id = $${paramIndex}`, values ); // Update invoice totals await this.updateTotals(invoiceId); const updated = await queryOne( `SELECT * FROM financial.invoice_lines WHERE id = $1`, [lineId] ); return updated!; } async removeLine(invoiceId: string, lineId: string, tenantId: string): Promise { const invoice = await this.findById(invoiceId, tenantId); if (invoice.status !== 'draft') { throw new ValidationError('Solo se pueden eliminar líneas de facturas en estado borrador'); } await query( `DELETE FROM financial.invoice_lines WHERE id = $1 AND invoice_id = $2`, [lineId, invoiceId] ); // Update invoice totals await this.updateTotals(invoiceId); } async validate(id: string, tenantId: string, userId: string): Promise { const invoice = await this.findById(id, tenantId); if (invoice.status !== 'draft') { throw new ValidationError('Solo se pueden validar facturas en estado borrador'); } if (!invoice.lines || invoice.lines.length === 0) { throw new ValidationError('La factura debe tener al menos una línea'); } logger.info('Validating invoice', { invoiceId: id, invoiceType: invoice.invoice_type }); // Generate invoice number using sequences service const sequenceCode = invoice.invoice_type === 'customer' ? SEQUENCE_CODES.INVOICE_CUSTOMER : SEQUENCE_CODES.INVOICE_SUPPLIER; const invoiceNumber = await sequencesService.getNextNumber(sequenceCode, tenantId); const client = await getClient(); try { await client.query('BEGIN'); // Update invoice status and number await client.query( `UPDATE financial.invoices SET number = $1, status = 'open', amount_residual = amount_total, validated_at = CURRENT_TIMESTAMP, validated_by = $2, updated_by = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $3 AND tenant_id = $4`, [invoiceNumber, userId, id, tenantId] ); await client.query('COMMIT'); // Get updated invoice with number for GL posting const validatedInvoice = await this.findById(id, tenantId); // Create journal entry for the invoice (GL posting) try { const invoiceForPosting: InvoiceForPosting = { id: validatedInvoice.id, tenant_id: validatedInvoice.tenant_id, company_id: validatedInvoice.company_id, partner_id: validatedInvoice.partner_id, partner_name: validatedInvoice.partner_name, invoice_type: validatedInvoice.invoice_type, number: validatedInvoice.number!, invoice_date: validatedInvoice.invoice_date, amount_untaxed: Number(validatedInvoice.amount_untaxed), amount_tax: Number(validatedInvoice.amount_tax), amount_total: Number(validatedInvoice.amount_total), journal_id: validatedInvoice.journal_id, lines: (validatedInvoice.lines || []).map(line => ({ id: line.id, product_id: line.product_id, description: line.description, quantity: Number(line.quantity), price_unit: Number(line.price_unit), amount_untaxed: Number(line.amount_untaxed), amount_tax: Number(line.amount_tax), amount_total: Number(line.amount_total), account_id: line.account_id, tax_ids: line.tax_ids || [], })), }; const postingResult = await glPostingService.createInvoicePosting(invoiceForPosting, userId); // Link journal entry to invoice await query( `UPDATE financial.invoices SET journal_entry_id = $1 WHERE id = $2`, [postingResult.journal_entry_id, id] ); logger.info('Invoice validated with GL posting', { invoiceId: id, invoiceNumber, journalEntryId: postingResult.journal_entry_id, journalEntryName: postingResult.journal_entry_name, }); } catch (postingError) { // Log error but don't fail the validation - GL posting can be done manually logger.error('Failed to create automatic GL posting', { invoiceId: id, error: (postingError as Error).message, }); // The invoice is still valid, just without automatic GL entry } return this.findById(id, tenantId); } catch (error) { await client.query('ROLLBACK'); throw error; } finally { client.release(); } } async cancel(id: string, tenantId: string, userId: string): Promise { const invoice = await this.findById(id, tenantId); if (invoice.status === 'paid') { throw new ValidationError('No se pueden cancelar facturas pagadas'); } if (invoice.status === 'cancelled') { throw new ValidationError('La factura ya está cancelada'); } if (invoice.amount_paid > 0) { throw new ValidationError('No se puede cancelar: la factura tiene pagos asociados'); } logger.info('Cancelling invoice', { invoiceId: id, invoiceNumber: invoice.number }); // Reverse journal entry if exists if (invoice.journal_entry_id) { try { await glPostingService.reversePosting( invoice.journal_entry_id, tenantId, `Cancelación de factura ${invoice.number}`, userId ); logger.info('Journal entry reversed for cancelled invoice', { invoiceId: id, journalEntryId: invoice.journal_entry_id, }); } catch (error) { logger.error('Failed to reverse journal entry', { invoiceId: id, journalEntryId: invoice.journal_entry_id, error: (error as Error).message, }); // Continue with cancellation even if reversal fails } } await query( `UPDATE financial.invoices SET status = 'cancelled', cancelled_at = CURRENT_TIMESTAMP, cancelled_by = $1, updated_by = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 AND tenant_id = $3`, [userId, id, tenantId] ); return this.findById(id, tenantId); } private async updateTotals(invoiceId: string): Promise { const totals = await queryOne<{ amount_untaxed: number; amount_tax: number; amount_total: number }>( `SELECT COALESCE(SUM(amount_untaxed), 0) as amount_untaxed, COALESCE(SUM(amount_tax), 0) as amount_tax, COALESCE(SUM(amount_total), 0) as amount_total FROM financial.invoice_lines WHERE invoice_id = $1`, [invoiceId] ); await query( `UPDATE financial.invoices SET amount_untaxed = $1, amount_tax = $2, amount_total = $3, amount_residual = $3 - amount_paid WHERE id = $4`, [totals?.amount_untaxed || 0, totals?.amount_tax || 0, totals?.amount_total || 0, invoiceId] ); } } export const invoicesService = new InvoicesService();