import { query, queryOne, getClient, PoolClient } from '../../config/database.js'; import { NotFoundError, ValidationError } from '../../shared/errors/index.js'; import { AccountMappingType } from './entities/account-mapping.entity.js'; import { sequencesService, SEQUENCE_CODES } from '../core/sequences.service.js'; import { logger } from '../../shared/utils/logger.js'; // ============================================================================ // TYPES // ============================================================================ export interface AccountMapping { id: string; tenant_id: string; company_id: string; mapping_type: AccountMappingType | string; account_id: string; account_code?: string; account_name?: string; description: string | null; is_active: boolean; } export interface JournalEntryLineInput { account_id: string; partner_id?: string; debit: number; credit: number; description?: string; ref?: string; } export interface PostingResult { journal_entry_id: string; journal_entry_name: string; total_debit: number; total_credit: number; lines_count: number; } export interface InvoiceForPosting { id: string; tenant_id: string; company_id: string; partner_id: string; partner_name?: string; invoice_type: 'customer' | 'supplier'; number: string; invoice_date: Date; amount_untaxed: number; amount_tax: number; amount_total: number; journal_id?: string; lines: InvoiceLineForPosting[]; } export interface InvoiceLineForPosting { id: string; product_id?: string; description: string; quantity: number; price_unit: number; amount_untaxed: number; amount_tax: number; amount_total: number; account_id?: string; tax_ids: string[]; } // ============================================================================ // SERVICE // ============================================================================ class GLPostingService { /** * Get account mapping for a specific type and company */ async getMapping( mappingType: AccountMappingType | string, tenantId: string, companyId: string ): Promise { const mapping = await queryOne( `SELECT am.*, a.code as account_code, a.name as account_name FROM financial.account_mappings am LEFT JOIN financial.accounts a ON am.account_id = a.id WHERE am.tenant_id = $1 AND am.company_id = $2 AND am.mapping_type = $3 AND am.is_active = true`, [tenantId, companyId, mappingType] ); return mapping; } /** * Get all active mappings for a company */ async getMappings(tenantId: string, companyId: string): Promise { return query( `SELECT am.*, a.code as account_code, a.name as account_name FROM financial.account_mappings am LEFT JOIN financial.accounts a ON am.account_id = a.id WHERE am.tenant_id = $1 AND am.company_id = $2 AND am.is_active = true ORDER BY am.mapping_type`, [tenantId, companyId] ); } /** * Create or update an account mapping */ async setMapping( mappingType: AccountMappingType | string, accountId: string, tenantId: string, companyId: string, description?: string, userId?: string ): Promise { const result = await queryOne( `INSERT INTO financial.account_mappings (tenant_id, company_id, mapping_type, account_id, description, created_by) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (tenant_id, company_id, mapping_type) DO UPDATE SET account_id = $4, description = $5, updated_by = $6, updated_at = CURRENT_TIMESTAMP RETURNING *`, [tenantId, companyId, mappingType, accountId, description, userId] ); return result!; } /** * Create a journal entry from a validated invoice * * For customer invoice (sale): * - Debit: Accounts Receivable (partner balance) * - Credit: Sales Revenue (per line or default mapping) * - Credit: Tax Payable (if taxes apply) * * For supplier invoice (bill): * - Credit: Accounts Payable (partner balance) * - Debit: Purchase Expense (per line or default mapping) * - Debit: Tax Receivable (if taxes apply) */ async createInvoicePosting( invoice: InvoiceForPosting, userId: string ): Promise { const { tenant_id: tenantId, company_id: companyId } = invoice; logger.info('Creating GL posting for invoice', { invoiceId: invoice.id, invoiceNumber: invoice.number, invoiceType: invoice.invoice_type, }); // Validate invoice has lines if (!invoice.lines || invoice.lines.length === 0) { throw new ValidationError('La factura debe tener al menos una línea para contabilizar'); } // Get required account mappings based on invoice type const isCustomerInvoice = invoice.invoice_type === 'customer'; // Get receivable/payable account const partnerAccountType = isCustomerInvoice ? AccountMappingType.CUSTOMER_INVOICE : AccountMappingType.SUPPLIER_INVOICE; const partnerMapping = await this.getMapping(partnerAccountType, tenantId, companyId); if (!partnerMapping) { throw new ValidationError( `No hay cuenta configurada para ${isCustomerInvoice ? 'Cuentas por Cobrar' : 'Cuentas por Pagar'}. Configure account_mappings.` ); } // Get default revenue/expense account const revenueExpenseType = isCustomerInvoice ? AccountMappingType.SALES_REVENUE : AccountMappingType.PURCHASE_EXPENSE; const revenueExpenseMapping = await this.getMapping(revenueExpenseType, tenantId, companyId); // Get tax accounts if there are taxes let taxPayableMapping: AccountMapping | null = null; let taxReceivableMapping: AccountMapping | null = null; if (invoice.amount_tax > 0) { if (isCustomerInvoice) { taxPayableMapping = await this.getMapping(AccountMappingType.TAX_PAYABLE, tenantId, companyId); if (!taxPayableMapping) { throw new ValidationError('No hay cuenta configurada para IVA por Pagar'); } } else { taxReceivableMapping = await this.getMapping(AccountMappingType.TAX_RECEIVABLE, tenantId, companyId); if (!taxReceivableMapping) { throw new ValidationError('No hay cuenta configurada para IVA por Recuperar'); } } } // Build journal entry lines const jeLines: JournalEntryLineInput[] = []; // Line 1: Partner account (AR/AP) if (isCustomerInvoice) { // Customer invoice: Debit AR jeLines.push({ account_id: partnerMapping.account_id, partner_id: invoice.partner_id, debit: invoice.amount_total, credit: 0, description: `Factura ${invoice.number} - ${invoice.partner_name || 'Cliente'}`, ref: invoice.number, }); } else { // Supplier invoice: Credit AP jeLines.push({ account_id: partnerMapping.account_id, partner_id: invoice.partner_id, debit: 0, credit: invoice.amount_total, description: `Factura ${invoice.number} - ${invoice.partner_name || 'Proveedor'}`, ref: invoice.number, }); } // Lines for each invoice line (revenue/expense) for (const line of invoice.lines) { // Use line's account_id if specified, otherwise use default mapping const lineAccountId = line.account_id || revenueExpenseMapping?.account_id; if (!lineAccountId) { throw new ValidationError( `No hay cuenta de ${isCustomerInvoice ? 'ingresos' : 'gastos'} configurada para la línea: ${line.description}` ); } if (isCustomerInvoice) { // Customer invoice: Credit Revenue jeLines.push({ account_id: lineAccountId, debit: 0, credit: line.amount_untaxed, description: line.description, ref: invoice.number, }); } else { // Supplier invoice: Debit Expense jeLines.push({ account_id: lineAccountId, debit: line.amount_untaxed, credit: 0, description: line.description, ref: invoice.number, }); } } // Tax line if applicable if (invoice.amount_tax > 0) { if (isCustomerInvoice && taxPayableMapping) { // Customer invoice: Credit Tax Payable jeLines.push({ account_id: taxPayableMapping.account_id, debit: 0, credit: invoice.amount_tax, description: `IVA - Factura ${invoice.number}`, ref: invoice.number, }); } else if (!isCustomerInvoice && taxReceivableMapping) { // Supplier invoice: Debit Tax Receivable jeLines.push({ account_id: taxReceivableMapping.account_id, debit: invoice.amount_tax, credit: 0, description: `IVA - Factura ${invoice.number}`, ref: invoice.number, }); } } // Validate balance const totalDebit = jeLines.reduce((sum, l) => sum + l.debit, 0); const totalCredit = jeLines.reduce((sum, l) => sum + l.credit, 0); if (Math.abs(totalDebit - totalCredit) > 0.01) { logger.error('Journal entry not balanced', { invoiceId: invoice.id, totalDebit, totalCredit, difference: totalDebit - totalCredit, }); throw new ValidationError( `El asiento contable no está balanceado. Débitos: ${totalDebit.toFixed(2)}, Créditos: ${totalCredit.toFixed(2)}` ); } // Get journal (use invoice's journal or find default) let journalId = invoice.journal_id; if (!journalId) { const journalType = isCustomerInvoice ? 'sale' : 'purchase'; const defaultJournal = await queryOne<{ id: string }>( `SELECT id FROM financial.journals WHERE tenant_id = $1 AND company_id = $2 AND type = $3 AND is_active = true LIMIT 1`, [tenantId, companyId, journalType] ); if (!defaultJournal) { throw new ValidationError( `No hay diario de ${isCustomerInvoice ? 'ventas' : 'compras'} configurado` ); } journalId = defaultJournal.id; } // Create journal entry const client = await getClient(); try { await client.query('BEGIN'); // Generate journal entry number const jeName = await sequencesService.getNextNumber(SEQUENCE_CODES.JOURNAL_ENTRY, tenantId); // Create entry header const entryResult = await client.query( `INSERT INTO financial.journal_entries ( tenant_id, company_id, journal_id, name, ref, date, notes, status, created_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, 'posted', $8) RETURNING id, name`, [ tenantId, companyId, journalId, jeName, invoice.number, invoice.invoice_date, `Asiento automático - Factura ${invoice.number}`, userId, ] ); const journalEntry = entryResult.rows[0]; // Create entry lines for (const line of jeLines) { await client.query( `INSERT INTO financial.journal_entry_lines ( entry_id, tenant_id, account_id, partner_id, debit, credit, description, ref ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [ journalEntry.id, tenantId, line.account_id, line.partner_id, line.debit, line.credit, line.description, line.ref, ] ); } // Update journal entry posted_at await client.query( `UPDATE financial.journal_entries SET posted_at = CURRENT_TIMESTAMP, posted_by = $1 WHERE id = $2`, [userId, journalEntry.id] ); await client.query('COMMIT'); logger.info('GL posting created successfully', { invoiceId: invoice.id, journalEntryId: journalEntry.id, journalEntryName: journalEntry.name, totalDebit, totalCredit, linesCount: jeLines.length, }); return { journal_entry_id: journalEntry.id, journal_entry_name: journalEntry.name, total_debit: totalDebit, total_credit: totalCredit, lines_count: jeLines.length, }; } catch (error) { await client.query('ROLLBACK'); logger.error('Error creating GL posting', { invoiceId: invoice.id, error: (error as Error).message, }); throw error; } finally { client.release(); } } /** * Create a journal entry from a posted payment * * For inbound payment (from customer): * - Debit: Cash/Bank account * - Credit: Accounts Receivable * * For outbound payment (to supplier): * - Credit: Cash/Bank account * - Debit: Accounts Payable */ async createPaymentPosting( payment: { id: string; tenant_id: string; company_id: string; partner_id: string; partner_name?: string; payment_type: 'inbound' | 'outbound'; amount: number; payment_date: Date; ref?: string; journal_id: string; }, userId: string, client?: PoolClient ): Promise { const { tenant_id: tenantId, company_id: companyId } = payment; const isInbound = payment.payment_type === 'inbound'; logger.info('Creating GL posting for payment', { paymentId: payment.id, paymentType: payment.payment_type, amount: payment.amount, }); // Get cash/bank account from journal const journal = await queryOne<{ default_debit_account_id: string; default_credit_account_id: string }>( `SELECT default_debit_account_id, default_credit_account_id FROM financial.journals WHERE id = $1`, [payment.journal_id] ); if (!journal) { throw new ValidationError('Diario de pago no encontrado'); } const cashAccountId = isInbound ? journal.default_debit_account_id : journal.default_credit_account_id; if (!cashAccountId) { throw new ValidationError('El diario no tiene cuenta de efectivo/banco configurada'); } // Get AR/AP account const partnerAccountType = isInbound ? AccountMappingType.CUSTOMER_PAYMENT : AccountMappingType.SUPPLIER_PAYMENT; let partnerMapping = await this.getMapping(partnerAccountType, tenantId, companyId); // Fall back to invoice mapping if payment-specific not configured if (!partnerMapping) { const fallbackType = isInbound ? AccountMappingType.CUSTOMER_INVOICE : AccountMappingType.SUPPLIER_INVOICE; partnerMapping = await this.getMapping(fallbackType, tenantId, companyId); } if (!partnerMapping) { throw new ValidationError( `No hay cuenta configurada para ${isInbound ? 'Cuentas por Cobrar' : 'Cuentas por Pagar'}` ); } // Build journal entry lines const jeLines: JournalEntryLineInput[] = []; const paymentRef = payment.ref || `PAY-${payment.id.substring(0, 8)}`; if (isInbound) { // Inbound: Debit Cash, Credit AR jeLines.push({ account_id: cashAccountId, debit: payment.amount, credit: 0, description: `Pago recibido - ${payment.partner_name || 'Cliente'}`, ref: paymentRef, }); jeLines.push({ account_id: partnerMapping.account_id, partner_id: payment.partner_id, debit: 0, credit: payment.amount, description: `Pago recibido - ${payment.partner_name || 'Cliente'}`, ref: paymentRef, }); } else { // Outbound: Credit Cash, Debit AP jeLines.push({ account_id: cashAccountId, debit: 0, credit: payment.amount, description: `Pago emitido - ${payment.partner_name || 'Proveedor'}`, ref: paymentRef, }); jeLines.push({ account_id: partnerMapping.account_id, partner_id: payment.partner_id, debit: payment.amount, credit: 0, description: `Pago emitido - ${payment.partner_name || 'Proveedor'}`, ref: paymentRef, }); } // Create journal entry const ownClient = !client; const dbClient = client || await getClient(); try { if (ownClient) { await dbClient.query('BEGIN'); } // Generate journal entry number const jeName = await sequencesService.getNextNumber(SEQUENCE_CODES.JOURNAL_ENTRY, tenantId); // Create entry header const entryResult = await dbClient.query( `INSERT INTO financial.journal_entries ( tenant_id, company_id, journal_id, name, ref, date, notes, status, created_by, posted_at, posted_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, 'posted', $8, CURRENT_TIMESTAMP, $8) RETURNING id, name`, [ tenantId, companyId, payment.journal_id, jeName, paymentRef, payment.payment_date, `Asiento automático - Pago ${paymentRef}`, userId, ] ); const journalEntry = entryResult.rows[0]; // Create entry lines for (const line of jeLines) { await dbClient.query( `INSERT INTO financial.journal_entry_lines ( entry_id, tenant_id, account_id, partner_id, debit, credit, description, ref ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [ journalEntry.id, tenantId, line.account_id, line.partner_id, line.debit, line.credit, line.description, line.ref, ] ); } if (ownClient) { await dbClient.query('COMMIT'); } logger.info('Payment GL posting created successfully', { paymentId: payment.id, journalEntryId: journalEntry.id, journalEntryName: journalEntry.name, }); return { journal_entry_id: journalEntry.id, journal_entry_name: journalEntry.name, total_debit: payment.amount, total_credit: payment.amount, lines_count: 2, }; } catch (error) { if (ownClient) { await dbClient.query('ROLLBACK'); } throw error; } finally { if (ownClient) { dbClient.release(); } } } /** * Reverse a journal entry (create a contra entry) */ async reversePosting( journalEntryId: string, tenantId: string, reason: string, userId: string ): Promise { // Get original entry const originalEntry = await queryOne<{ id: string; company_id: string; journal_id: string; name: string; ref: string; date: Date; }>( `SELECT id, company_id, journal_id, name, ref, date FROM financial.journal_entries WHERE id = $1 AND tenant_id = $2`, [journalEntryId, tenantId] ); if (!originalEntry) { throw new NotFoundError('Asiento contable no encontrado'); } // Get original lines const originalLines = await query( `SELECT account_id, partner_id, debit, credit, description, ref FROM financial.journal_entry_lines WHERE entry_id = $1`, [journalEntryId] ); // Reverse debits and credits const reversedLines: JournalEntryLineInput[] = originalLines.map(line => ({ account_id: line.account_id, partner_id: line.partner_id, debit: line.credit, // Swap credit: line.debit, // Swap description: `Reverso: ${line.description || ''}`, ref: line.ref, })); const client = await getClient(); try { await client.query('BEGIN'); // Generate new entry number const jeName = await sequencesService.getNextNumber(SEQUENCE_CODES.JOURNAL_ENTRY, tenantId); // Create reversal entry const entryResult = await client.query( `INSERT INTO financial.journal_entries ( tenant_id, company_id, journal_id, name, ref, date, notes, status, created_by, posted_at, posted_by ) VALUES ($1, $2, $3, $4, $5, CURRENT_DATE, $6, 'posted', $7, CURRENT_TIMESTAMP, $7) RETURNING id, name`, [ tenantId, originalEntry.company_id, originalEntry.journal_id, jeName, `REV-${originalEntry.name}`, `Reverso de ${originalEntry.name}: ${reason}`, userId, ] ); const reversalEntry = entryResult.rows[0]; // Create reversal lines for (const line of reversedLines) { await client.query( `INSERT INTO financial.journal_entry_lines ( entry_id, tenant_id, account_id, partner_id, debit, credit, description, ref ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [ reversalEntry.id, tenantId, line.account_id, line.partner_id, line.debit, line.credit, line.description, line.ref, ] ); } // Mark original as cancelled await client.query( `UPDATE financial.journal_entries SET status = 'cancelled', cancelled_at = CURRENT_TIMESTAMP, cancelled_by = $1 WHERE id = $2`, [userId, journalEntryId] ); await client.query('COMMIT'); const totalDebit = reversedLines.reduce((sum, l) => sum + l.debit, 0); logger.info('GL posting reversed', { originalEntryId: journalEntryId, reversalEntryId: reversalEntry.id, reason, }); return { journal_entry_id: reversalEntry.id, journal_entry_name: reversalEntry.name, total_debit: totalDebit, total_credit: totalDebit, lines_count: reversedLines.length, }; } catch (error) { await client.query('ROLLBACK'); throw error; } finally { client.release(); } } } export const glPostingService = new GLPostingService();