import { query, queryOne, getClient } from '../../../config/database.js'; import { NotFoundError, ValidationError } from '../../../shared/errors/index.js'; import { CreateBankStatementDto, BankStatementFilters, BankStatementWithLines, BankStatementLineResponse, SuggestedMatch, } from '../dto/create-bank-statement.dto.js'; import { ReconcileResult, AutoReconcileResult, MatchCandidate, FindMatchCandidatesDto, CreateReconciliationRuleDto, UpdateReconciliationRuleDto, } from '../dto/reconcile-line.dto.js'; /** * Representacion de extracto bancario */ export interface BankStatement { id: string; tenant_id: string; company_id: string | null; bank_account_id: string | null; bank_account_name?: string; statement_date: Date; opening_balance: number; closing_balance: number; status: 'draft' | 'reconciling' | 'reconciled'; imported_at: Date | null; imported_by: string | null; reconciled_at: Date | null; reconciled_by: string | null; created_at: Date; } /** * Representacion de regla de conciliacion */ export interface ReconciliationRule { id: string; tenant_id: string; company_id: string | null; name: string; match_type: 'exact_amount' | 'reference_contains' | 'partner_name'; match_value: string; auto_account_id: string | null; auto_account_name?: string; is_active: boolean; priority: number; created_at: Date; } /** * Servicio para conciliacion bancaria */ class BankReconciliationService { // ========================================== // EXTRACTOS BANCARIOS // ========================================== /** * Importar un extracto bancario con sus lineas */ async importStatement( dto: CreateBankStatementDto, tenantId: string, userId: string ): Promise { // Validaciones if (!dto.lines || dto.lines.length === 0) { throw new ValidationError('El extracto debe tener al menos una linea'); } // Validar que el balance cuadre const calculatedClosing = dto.opening_balance + dto.lines.reduce((sum, line) => sum + line.amount, 0); if (Math.abs(calculatedClosing - dto.closing_balance) > 0.01) { throw new ValidationError( `El balance no cuadra. Apertura (${dto.opening_balance}) + Movimientos (${dto.lines.reduce((s, l) => s + l.amount, 0)}) = ${calculatedClosing}, pero cierre declarado es ${dto.closing_balance}` ); } const client = await getClient(); try { await client.query('BEGIN'); // Crear el extracto const statementResult = await client.query( `INSERT INTO financial.bank_statements ( tenant_id, company_id, bank_account_id, statement_date, opening_balance, closing_balance, status, imported_at, imported_by, created_by ) VALUES ($1, $2, $3, $4, $5, $6, 'draft', CURRENT_TIMESTAMP, $7, $7) RETURNING *`, [ tenantId, dto.company_id || null, dto.bank_account_id || null, dto.statement_date, dto.opening_balance, dto.closing_balance, userId, ] ); const statement = statementResult.rows[0]; // Insertar las lineas for (const line of dto.lines) { await client.query( `INSERT INTO financial.bank_statement_lines ( statement_id, tenant_id, transaction_date, value_date, description, reference, amount, partner_id, notes ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, [ statement.id, tenantId, line.transaction_date, line.value_date || null, line.description || null, line.reference || null, line.amount, line.partner_id || null, line.notes || null, ] ); } await client.query('COMMIT'); return statement; } catch (error) { await client.query('ROLLBACK'); throw error; } finally { client.release(); } } /** * Obtener lista de extractos con filtros */ async findAll( tenantId: string, filters: BankStatementFilters = {} ): Promise<{ data: BankStatement[]; total: number }> { const { company_id, bank_account_id, status, date_from, date_to, page = 1, limit = 20 } = filters; const offset = (page - 1) * limit; let whereClause = 'WHERE bs.tenant_id = $1'; const params: unknown[] = [tenantId]; let paramIndex = 2; if (company_id) { whereClause += ` AND bs.company_id = $${paramIndex++}`; params.push(company_id); } if (bank_account_id) { whereClause += ` AND bs.bank_account_id = $${paramIndex++}`; params.push(bank_account_id); } if (status) { whereClause += ` AND bs.status = $${paramIndex++}`; params.push(status); } if (date_from) { whereClause += ` AND bs.statement_date >= $${paramIndex++}`; params.push(date_from); } if (date_to) { whereClause += ` AND bs.statement_date <= $${paramIndex++}`; params.push(date_to); } const countResult = await queryOne<{ count: string }>( `SELECT COUNT(*) as count FROM financial.bank_statements bs ${whereClause}`, params ); params.push(limit, offset); const data = await query( `SELECT bs.*, a.name as bank_account_name, financial.get_reconciliation_progress(bs.id) as reconciliation_progress FROM financial.bank_statements bs LEFT JOIN financial.accounts a ON bs.bank_account_id = a.id ${whereClause} ORDER BY bs.statement_date DESC, bs.created_at DESC LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, params ); return { data, total: parseInt(countResult?.count || '0', 10), }; } /** * Obtener un extracto con todas sus lineas */ async getStatementWithLines(id: string, tenantId: string): Promise { const statement = await queryOne( `SELECT bs.*, a.name as bank_account_name, financial.get_reconciliation_progress(bs.id) as reconciliation_progress FROM financial.bank_statements bs LEFT JOIN financial.accounts a ON bs.bank_account_id = a.id WHERE bs.id = $1 AND bs.tenant_id = $2`, [id, tenantId] ); if (!statement) { throw new NotFoundError('Extracto bancario no encontrado'); } // Obtener lineas const lines = await query( `SELECT bsl.*, p.name as partner_name FROM financial.bank_statement_lines bsl LEFT JOIN core.partners p ON bsl.partner_id = p.id WHERE bsl.statement_id = $1 ORDER BY bsl.transaction_date, bsl.created_at`, [id] ); // Calcular balance calculado const calculatedBalance = Number(statement.opening_balance) + lines.reduce((sum, line) => sum + Number(line.amount), 0); statement.lines = lines; statement.calculated_balance = calculatedBalance; return statement; } /** * Eliminar un extracto (solo en estado draft) */ async deleteStatement(id: string, tenantId: string): Promise { const statement = await queryOne( `SELECT * FROM financial.bank_statements WHERE id = $1 AND tenant_id = $2`, [id, tenantId] ); if (!statement) { throw new NotFoundError('Extracto bancario no encontrado'); } if (statement.status !== 'draft') { throw new ValidationError('Solo se pueden eliminar extractos en estado borrador'); } await query(`DELETE FROM financial.bank_statements WHERE id = $1`, [id]); } // ========================================== // CONCILIACION // ========================================== /** * Ejecutar auto-conciliacion de un extracto * Busca matches automaticos por monto, fecha y referencia */ async autoReconcile(statementId: string, tenantId: string, userId: string): Promise { const statement = await this.getStatementWithLines(statementId, tenantId); if (statement.status === 'reconciled') { throw new ValidationError('El extracto ya esta completamente conciliado'); } // Cambiar estado a reconciling si esta en draft if (statement.status === 'draft') { await query( `UPDATE financial.bank_statements SET status = 'reconciling', updated_by = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2`, [userId, statementId] ); } const result: AutoReconcileResult = { total_lines: statement.lines.length, reconciled_count: 0, unreconciled_count: 0, reconciled_lines: [], lines_with_suggestions: [], }; // Obtener reglas activas const rules = await query( `SELECT * FROM financial.bank_reconciliation_rules WHERE tenant_id = $1 AND is_active = true ORDER BY priority DESC`, [tenantId] ); // Procesar cada linea no conciliada for (const line of statement.lines) { if (line.is_reconciled) { continue; } // Buscar candidatos a match const candidates = await this.findMatchCandidates( { amount: Math.abs(Number(line.amount)), date: line.transaction_date.toString(), reference: line.reference || undefined, partner_id: line.partner_id || undefined, amount_tolerance: 0, date_tolerance_days: 3, limit: 5, }, tenantId, statement.bank_account_id || undefined ); // Aplicar reglas personalizadas for (const rule of rules) { const ruleMatch = this.applyRule(rule, line); if (ruleMatch) { // Si la regla tiene cuenta auto, se podria crear asiento automatico // Por ahora solo marcamos como sugerencia } } // Si hay un match con confianza >= 90%, conciliar automaticamente const exactMatch = candidates.find((c) => c.confidence >= 90); if (exactMatch) { try { await this.reconcileLine(line.id, exactMatch.id, tenantId, userId); result.reconciled_count++; result.reconciled_lines.push({ statement_line_id: line.id, entry_line_id: exactMatch.id, match_type: exactMatch.match_type, confidence: exactMatch.confidence, }); } catch { // Si falla, agregar a sugerencias result.lines_with_suggestions.push({ statement_line_id: line.id, suggestions: candidates.length, }); result.unreconciled_count++; } } else if (candidates.length > 0) { result.lines_with_suggestions.push({ statement_line_id: line.id, suggestions: candidates.length, }); result.unreconciled_count++; } else { result.unreconciled_count++; } } return result; } /** * Buscar lineas de asiento candidatas a conciliar */ async findMatchCandidates( dto: FindMatchCandidatesDto, tenantId: string, bankAccountId?: string ): Promise { const { amount, date, reference, partner_id, amount_tolerance = 0, date_tolerance_days = 3, limit = 10 } = dto; let whereClause = ` WHERE jel.tenant_id = $1 AND je.status = 'posted' AND NOT EXISTS ( SELECT 1 FROM financial.bank_statement_lines bsl WHERE bsl.reconciled_entry_id = jel.id ) `; const params: unknown[] = [tenantId]; let paramIndex = 2; // Filtrar por cuenta bancaria si se especifica if (bankAccountId) { whereClause += ` AND jel.account_id = $${paramIndex++}`; params.push(bankAccountId); } // Filtrar por monto con tolerancia const amountMin = amount * (1 - amount_tolerance); const amountMax = amount * (1 + amount_tolerance); whereClause += ` AND ( (jel.debit BETWEEN $${paramIndex} AND $${paramIndex + 1}) OR (jel.credit BETWEEN $${paramIndex} AND $${paramIndex + 1}) )`; params.push(amountMin, amountMax); paramIndex += 2; // Filtrar por fecha con tolerancia if (date) { whereClause += ` AND je.date BETWEEN ($${paramIndex}::date - interval '${date_tolerance_days} days') AND ($${paramIndex}::date + interval '${date_tolerance_days} days')`; params.push(date); paramIndex++; } // Filtrar por partner si se especifica if (partner_id) { whereClause += ` AND jel.partner_id = $${paramIndex++}`; params.push(partner_id); } params.push(limit); const candidates = await query( `SELECT jel.id, jel.entry_id, je.name as entry_name, je.ref as entry_ref, je.date as entry_date, jel.account_id, a.code as account_code, a.name as account_name, jel.debit, jel.credit, (jel.debit - jel.credit) as net_amount, jel.description, jel.partner_id, p.name as partner_name FROM financial.journal_entry_lines jel INNER JOIN financial.journal_entries je ON jel.entry_id = je.id INNER JOIN financial.accounts a ON jel.account_id = a.id LEFT JOIN core.partners p ON jel.partner_id = p.id ${whereClause} ORDER BY je.date DESC LIMIT $${paramIndex}`, params ); // Calcular confianza y tipo de match para cada candidato return candidates.map((c) => { let confidence = 50; // Base let matchType: MatchCandidate['match_type'] = 'exact_amount'; const candidateAmount = Math.abs(Number(c.debit) - Number(c.credit)); // Match exacto de monto if (Math.abs(candidateAmount - amount) < 0.01) { confidence += 30; matchType = 'exact_amount'; } // Match de fecha exacta if (date && c.entry_date.toString().substring(0, 10) === date.substring(0, 10)) { confidence += 15; matchType = 'amount_date'; } // Match de referencia if (reference && c.entry_ref && c.entry_ref.toLowerCase().includes(reference.toLowerCase())) { confidence += 20; matchType = 'reference'; } // Match de partner if (partner_id && c.partner_id === partner_id) { confidence += 15; matchType = 'partner'; } return { ...c, match_type: matchType, confidence: Math.min(100, confidence), }; }); } /** * Conciliar manualmente una linea de extracto con una linea de asiento */ async reconcileLine( lineId: string, entryLineId: string, tenantId: string, userId: string ): Promise { // Verificar que la linea de extracto existe y no esta conciliada const line = await queryOne( `SELECT * FROM financial.bank_statement_lines WHERE id = $1 AND tenant_id = $2`, [lineId, tenantId] ); if (!line) { throw new NotFoundError('Linea de extracto no encontrada'); } if (line.is_reconciled) { throw new ValidationError('La linea ya esta conciliada'); } // Verificar que la linea de asiento existe y no esta conciliada con otra linea const entryLine = await queryOne<{ id: string; debit: number; credit: number }>( `SELECT jel.* FROM financial.journal_entry_lines jel INNER JOIN financial.journal_entries je ON jel.entry_id = je.id WHERE jel.id = $1 AND jel.tenant_id = $2 AND je.status = 'posted'`, [entryLineId, tenantId] ); if (!entryLine) { throw new NotFoundError('Linea de asiento no encontrada o no publicada'); } // Verificar que no este ya conciliada const alreadyReconciled = await queryOne<{ id: string }>( `SELECT id FROM financial.bank_statement_lines WHERE reconciled_entry_id = $1`, [entryLineId] ); if (alreadyReconciled) { throw new ValidationError('La linea de asiento ya esta conciliada con otra linea de extracto'); } // Actualizar la linea de extracto await query( `UPDATE financial.bank_statement_lines SET is_reconciled = true, reconciled_entry_id = $1, reconciled_at = CURRENT_TIMESTAMP, reconciled_by = $2 WHERE id = $3`, [entryLineId, userId, lineId] ); return { success: true, statement_line_id: lineId, entry_line_id: entryLineId, }; } /** * Deshacer la conciliacion de una linea */ async unreconcileLine(lineId: string, tenantId: string): Promise { const line = await queryOne( `SELECT * FROM financial.bank_statement_lines WHERE id = $1 AND tenant_id = $2`, [lineId, tenantId] ); if (!line) { throw new NotFoundError('Linea de extracto no encontrada'); } if (!line.is_reconciled) { throw new ValidationError('La linea no esta conciliada'); } await query( `UPDATE financial.bank_statement_lines SET is_reconciled = false, reconciled_entry_id = NULL, reconciled_at = NULL, reconciled_by = NULL WHERE id = $1`, [lineId] ); } /** * Cerrar un extracto completamente conciliado */ async closeStatement(statementId: string, tenantId: string, userId: string): Promise { const statement = await this.getStatementWithLines(statementId, tenantId); if (statement.status === 'reconciled') { throw new ValidationError('El extracto ya esta cerrado'); } // Verificar que todas las lineas esten conciliadas const unreconciledLines = statement.lines.filter((l) => !l.is_reconciled); if (unreconciledLines.length > 0) { throw new ValidationError( `No se puede cerrar el extracto. Hay ${unreconciledLines.length} linea(s) sin conciliar` ); } await query( `UPDATE financial.bank_statements SET status = 'reconciled', reconciled_at = CURRENT_TIMESTAMP, reconciled_by = $1, updated_by = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2`, [userId, statementId] ); return this.findById(statementId, tenantId); } /** * Obtener un extracto por ID */ async findById(id: string, tenantId: string): Promise { const statement = await queryOne( `SELECT bs.*, a.name as bank_account_name FROM financial.bank_statements bs LEFT JOIN financial.accounts a ON bs.bank_account_id = a.id WHERE bs.id = $1 AND bs.tenant_id = $2`, [id, tenantId] ); if (!statement) { throw new NotFoundError('Extracto bancario no encontrado'); } return statement; } // ========================================== // REGLAS DE CONCILIACION // ========================================== /** * Crear una regla de conciliacion */ async createRule(dto: CreateReconciliationRuleDto, tenantId: string, userId: string): Promise { const result = await queryOne( `INSERT INTO financial.bank_reconciliation_rules ( tenant_id, company_id, name, match_type, match_value, auto_account_id, priority, is_active, created_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`, [ tenantId, dto.company_id || null, dto.name, dto.match_type, dto.match_value, dto.auto_account_id || null, dto.priority || 0, dto.is_active !== false, userId, ] ); return result!; } /** * Obtener reglas de conciliacion */ async findRules(tenantId: string, companyId?: string): Promise { let whereClause = 'WHERE r.tenant_id = $1'; const params: unknown[] = [tenantId]; if (companyId) { whereClause += ' AND (r.company_id = $2 OR r.company_id IS NULL)'; params.push(companyId); } return query( `SELECT r.*, a.name as auto_account_name FROM financial.bank_reconciliation_rules r LEFT JOIN financial.accounts a ON r.auto_account_id = a.id ${whereClause} ORDER BY r.priority DESC, r.name`, params ); } /** * Actualizar una regla de conciliacion */ async updateRule( id: string, dto: UpdateReconciliationRuleDto, tenantId: string, userId: string ): Promise { const existing = await queryOne( `SELECT * FROM financial.bank_reconciliation_rules WHERE id = $1 AND tenant_id = $2`, [id, tenantId] ); if (!existing) { throw new NotFoundError('Regla de conciliacion no encontrada'); } const updateFields: string[] = []; const values: unknown[] = []; let paramIndex = 1; if (dto.name !== undefined) { updateFields.push(`name = $${paramIndex++}`); values.push(dto.name); } if (dto.match_type !== undefined) { updateFields.push(`match_type = $${paramIndex++}`); values.push(dto.match_type); } if (dto.match_value !== undefined) { updateFields.push(`match_value = $${paramIndex++}`); values.push(dto.match_value); } if (dto.auto_account_id !== undefined) { updateFields.push(`auto_account_id = $${paramIndex++}`); values.push(dto.auto_account_id); } if (dto.priority !== undefined) { updateFields.push(`priority = $${paramIndex++}`); values.push(dto.priority); } if (dto.is_active !== undefined) { updateFields.push(`is_active = $${paramIndex++}`); values.push(dto.is_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.bank_reconciliation_rules SET ${updateFields.join(', ')} WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`, values ); return this.findRuleById(id, tenantId); } /** * Obtener regla por ID */ async findRuleById(id: string, tenantId: string): Promise { const rule = await queryOne( `SELECT r.*, a.name as auto_account_name FROM financial.bank_reconciliation_rules r LEFT JOIN financial.accounts a ON r.auto_account_id = a.id WHERE r.id = $1 AND r.tenant_id = $2`, [id, tenantId] ); if (!rule) { throw new NotFoundError('Regla de conciliacion no encontrada'); } return rule; } /** * Eliminar una regla */ async deleteRule(id: string, tenantId: string): Promise { const existing = await queryOne( `SELECT * FROM financial.bank_reconciliation_rules WHERE id = $1 AND tenant_id = $2`, [id, tenantId] ); if (!existing) { throw new NotFoundError('Regla de conciliacion no encontrada'); } await query(`DELETE FROM financial.bank_reconciliation_rules WHERE id = $1`, [id]); } // ========================================== // HELPERS // ========================================== /** * Aplicar una regla a una linea de extracto */ private applyRule( rule: ReconciliationRule, line: BankStatementLineResponse ): boolean { switch (rule.match_type) { case 'exact_amount': return Math.abs(Number(line.amount)) === parseFloat(rule.match_value); case 'reference_contains': return line.reference?.toLowerCase().includes(rule.match_value.toLowerCase()) || false; case 'partner_name': // Esto requeriria el nombre del partner, que ya esta en partner_name return line.partner_name?.toLowerCase().includes(rule.match_value.toLowerCase()) || false; default: return false; } } } export const bankReconciliationService = new BankReconciliationService();