# SPEC-CONCILIACION-BANCARIA: Conciliación Bancaria Automática ## Metadata - **Código**: SPEC-MGN-004 - **Módulo**: Tesorería / Contabilidad - **Gap Relacionado**: GAP-MGN-004-002 - **Prioridad**: P1 - **Esfuerzo Estimado**: 21 SP - **Versión**: 1.0 - **Última Actualización**: 2025-01-09 - **Referencia Odoo**: account_accountant, account_bank_statement --- ## 1. Resumen Ejecutivo ### 1.1 Objetivo Implementar un sistema de conciliación bancaria automática que permita: - Importar extractos bancarios en formatos estándar (CAMT.053, OFX, MT940) - Aplicar reglas configurables de coincidencia automática - Detectar contrapartes mediante patrones de texto - Manejar tolerancias de pago para diferencias menores - Soportar conciliación parcial y múltiple ### 1.2 Alcance - Gestión de extractos bancarios (statements) - Motor de reglas de conciliación - Algoritmo de matching automático - Detección de partners por texto - Manejo de diferencias (write-offs) - Conciliación multi-moneda --- ## 2. Modelo de Datos ### 2.1 Diagrama Entidad-Relación ``` ┌─────────────────────────────┐ │ bank_statements │ │─────────────────────────────│ │ id (PK) │ │ name │ │ reference │ │ journal_id (FK) │ │ date │ │ balance_start │ │ balance_end │ │ balance_end_real │ │ is_complete │ │ is_valid │ └──────────────┬──────────────┘ │ 1:N ▼ ┌─────────────────────────────┐ ┌─────────────────────────────┐ │ bank_statement_lines │ │ reconcile_models │ │─────────────────────────────│ │─────────────────────────────│ │ id (PK) │ │ id (PK) │ │ statement_id (FK) │ │ company_id (FK) │ │ move_id (FK) │ │ name │ │ journal_id (FK) │ │ sequence │ │ partner_id (FK) │ │ rule_type │ │ partner_name │ │ auto_reconcile │ │ account_number │ │ matching_order │ │ payment_ref │ │ match_journal_ids │ │ amount │ │ match_amount_type │ │ amount_currency │ │ match_amount_min/max │ │ foreign_currency_id (FK) │ │ match_nature │ │ running_balance │ │ match_label │ │ transaction_type │ │ match_label_param │ │ amount_residual │ │ payment_tolerance_type │ │ is_reconciled │ │ payment_tolerance_param │ │ internal_index │ │ past_months_limit │ │ transaction_details (JSON)│ └─────────────┬───────────────┘ └──────────────┬──────────────┘ │ │ │ 1:N │ ┌─────────────▼───────────────┐ │ │ reconcile_model_lines │ │ │─────────────────────────────│ │ │ id (PK) │ │ │ model_id (FK) │ │ │ account_id (FK) │ │ │ amount_type │ │ │ amount_string │ │ │ label │ │ │ tax_ids │ │ └─────────────────────────────┘ │ │ ┌─────────────────────────────┐ │ │ reconcile_partner_mapping │ ▼ │─────────────────────────────│ ┌─────────────────────────────┐ │ id (PK) │ │ partial_reconciles │ │ model_id (FK) │ │─────────────────────────────│ │ partner_id (FK) │ │ id (PK) │ │ payment_ref_regex │ │ debit_move_line_id (FK) │ │ narration_regex │ │ credit_move_line_id (FK) │ └─────────────────────────────┘ │ full_reconcile_id (FK) │ │ amount │ │ debit_amount_currency │ │ credit_amount_currency │ │ max_date │ │ exchange_move_id (FK) │ └──────────────┬──────────────┘ │ N:1 ▼ ┌─────────────────────────────┐ │ full_reconciles │ │─────────────────────────────│ │ id (PK) │ │ name │ │ exchange_move_id (FK) │ └─────────────────────────────┘ ``` ### 2.2 Definición de Tablas #### 2.2.1 treasury.bank_statements ```sql CREATE TABLE treasury.bank_statements ( -- Identificación id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), name VARCHAR(64) NOT NULL, reference VARCHAR(256), -- Relaciones journal_id UUID NOT NULL REFERENCES accounting.journals(id), company_id UUID NOT NULL REFERENCES core.companies(id), currency_id UUID NOT NULL REFERENCES core.currencies(id), -- Saldos date DATE, first_line_index VARCHAR(64), balance_start DECIMAL(20,6) NOT NULL DEFAULT 0, balance_end DECIMAL(20,6) GENERATED ALWAYS AS ( balance_start + COALESCE(( SELECT SUM(amount) FROM treasury.bank_statement_lines WHERE statement_id = id AND state = 'posted' ), 0) ) STORED, balance_end_real DECIMAL(20,6) NOT NULL DEFAULT 0, -- Estado is_complete BOOLEAN GENERATED ALWAYS AS ( ABS(balance_end - balance_end_real) < 0.01 ) STORED, is_valid BOOLEAN DEFAULT true, -- Auditoría created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_by UUID REFERENCES core.users(id), updated_by UUID REFERENCES core.users(id), -- Constraints CONSTRAINT uk_bank_statement_name_journal UNIQUE (name, journal_id) ); -- Índices CREATE INDEX idx_bank_statements_journal_date ON treasury.bank_statements(journal_id, date DESC, id DESC); CREATE INDEX idx_bank_statements_company ON treasury.bank_statements(company_id); ``` #### 2.2.2 treasury.bank_statement_lines ```sql CREATE TABLE treasury.bank_statement_lines ( -- Identificación id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), sequence INTEGER NOT NULL DEFAULT 10, -- Relaciones statement_id UUID NOT NULL REFERENCES treasury.bank_statements(id) ON DELETE CASCADE, move_id UUID REFERENCES accounting.moves(id), journal_id UUID NOT NULL REFERENCES accounting.journals(id), partner_id UUID REFERENCES core.partners(id), -- Información de la transacción partner_name VARCHAR(256), account_number VARCHAR(64), payment_ref VARCHAR(512), transaction_type VARCHAR(32), -- Montos amount DECIMAL(20,6) NOT NULL, amount_currency DECIMAL(20,6), foreign_currency_id UUID REFERENCES core.currencies(id), currency_id UUID NOT NULL REFERENCES core.currencies(id), -- Balance acumulado running_balance DECIMAL(20,6), -- Estado de conciliación amount_residual DECIMAL(20,6) NOT NULL DEFAULT 0, is_reconciled BOOLEAN NOT NULL DEFAULT false, -- Índice interno para ordenamiento (YYYYMMDD + sequence_complement + id) internal_index VARCHAR(64) NOT NULL, -- Detalles de importación (JSON estructurado) transaction_details JSONB DEFAULT '{}', -- Estado state VARCHAR(16) NOT NULL DEFAULT 'draft' CHECK (state IN ('draft', 'posted', 'cancelled')), -- Auditoría created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- Constraints CONSTRAINT chk_amount_not_zero CHECK (amount != 0) ); -- Índices optimizados CREATE INDEX idx_bsl_main ON treasury.bank_statement_lines(journal_id, company_id, internal_index) WHERE state = 'posted'; CREATE INDEX idx_bsl_unreconciled ON treasury.bank_statement_lines(journal_id, internal_index) WHERE NOT is_reconciled AND state = 'posted'; CREATE INDEX idx_bsl_statement ON treasury.bank_statement_lines(statement_id, sequence); -- Índice GIN para búsqueda en detalles CREATE INDEX idx_bsl_transaction_details ON treasury.bank_statement_lines USING GIN (transaction_details); ``` #### 2.2.3 treasury.reconcile_models ```sql CREATE TABLE treasury.reconcile_models ( -- Identificación id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), name VARCHAR(128) NOT NULL, sequence INTEGER NOT NULL DEFAULT 10, -- Relaciones company_id UUID NOT NULL REFERENCES core.companies(id), -- Tipo de regla rule_type VARCHAR(32) NOT NULL DEFAULT 'writeoff_suggestion' CHECK (rule_type IN ( 'writeoff_button', -- Botón manual de contrapartida 'writeoff_suggestion', -- Sugerencia automática de contrapartida 'invoice_matching' -- Matching contra facturas/pagos )), -- Configuración de auto-reconciliación auto_reconcile BOOLEAN NOT NULL DEFAULT false, to_check BOOLEAN NOT NULL DEFAULT false, -- Orden de matching matching_order VARCHAR(16) NOT NULL DEFAULT 'old_first' CHECK (matching_order IN ('old_first', 'new_first')), -- Tipo de contrapartida (para writeoff rules) counterpart_type VARCHAR(16) DEFAULT 'general' CHECK (counterpart_type IN ('general', 'sale', 'purchase')), -- Condiciones de aplicación is_active BOOLEAN NOT NULL DEFAULT true, -- Auditoría created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_by UUID REFERENCES core.users(id), -- Constraints CONSTRAINT uk_reconcile_model_name_company UNIQUE (name, company_id) ); -- Tabla de condiciones del modelo CREATE TABLE treasury.reconcile_model_conditions ( id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), model_id UUID NOT NULL REFERENCES treasury.reconcile_models(id) ON DELETE CASCADE, -- Condición de journals match_journal_ids UUID[] DEFAULT '{}', -- Condición de naturaleza (tipo de transacción) match_nature VARCHAR(16) DEFAULT 'both' CHECK (match_nature IN ('amount_received', 'amount_paid', 'both')), -- Condición de monto match_amount VARCHAR(16) CHECK (match_amount IN ('lower', 'greater', 'between')), match_amount_min DECIMAL(20,6) DEFAULT 0, match_amount_max DECIMAL(20,6) DEFAULT 0, -- Condición de moneda match_same_currency BOOLEAN NOT NULL DEFAULT true, -- Condiciones de texto (label/reference) match_label VARCHAR(16) CHECK (match_label IN ( 'contains', 'not_contains', 'match_regex' )), match_label_param VARCHAR(512), -- Condiciones de notas match_note VARCHAR(16) CHECK (match_note IN ( 'contains', 'not_contains', 'match_regex' )), match_note_param VARCHAR(512), -- Condiciones de tipo de transacción match_transaction_type VARCHAR(16) CHECK (match_transaction_type IN ( 'contains', 'not_contains', 'match_regex' )), match_transaction_type_param VARCHAR(128), -- Condiciones de partner match_partner BOOLEAN NOT NULL DEFAULT false, match_partner_ids UUID[] DEFAULT '{}', match_partner_category_ids UUID[] DEFAULT '{}', -- Ventana histórica de búsqueda past_months_limit INTEGER NOT NULL DEFAULT 18, CONSTRAINT uk_model_conditions UNIQUE (model_id) ); -- Tolerancia de pago (solo para invoice_matching) CREATE TABLE treasury.reconcile_model_tolerance ( id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), model_id UUID NOT NULL REFERENCES treasury.reconcile_models(id) ON DELETE CASCADE, -- Configuración de tolerancia allow_payment_tolerance BOOLEAN NOT NULL DEFAULT false, payment_tolerance_type VARCHAR(16) DEFAULT 'percentage' CHECK (payment_tolerance_type IN ('percentage', 'fixed_amount')), payment_tolerance_param DECIMAL(10,4) DEFAULT 0, -- Cuenta para diferencias tolerance_account_id UUID REFERENCES accounting.accounts(id), CONSTRAINT uk_model_tolerance UNIQUE (model_id), CONSTRAINT chk_tolerance_param CHECK ( payment_tolerance_param >= 0 AND (payment_tolerance_type != 'percentage' OR payment_tolerance_param <= 100) ) ); -- Índices CREATE INDEX idx_reconcile_models_company_sequence ON treasury.reconcile_models(company_id, sequence) WHERE is_active; ``` #### 2.2.4 treasury.reconcile_model_lines ```sql CREATE TABLE treasury.reconcile_model_lines ( id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), model_id UUID NOT NULL REFERENCES treasury.reconcile_models(id) ON DELETE CASCADE, sequence INTEGER NOT NULL DEFAULT 10, -- Cuenta de contrapartida account_id UUID NOT NULL REFERENCES accounting.accounts(id), -- Tipo de cálculo del monto amount_type VARCHAR(32) NOT NULL DEFAULT 'percentage' CHECK (amount_type IN ( 'fixed', -- Monto fijo 'percentage', -- Porcentaje del balance 'percentage_st_line', -- Porcentaje de la línea del extracto 'regex' -- Extraer de la etiqueta con regex )), -- Valor o patrón para el monto amount_string VARCHAR(256) NOT NULL DEFAULT '100', -- Etiqueta para el asiento label VARCHAR(256), -- Impuestos a aplicar tax_ids UUID[] DEFAULT '{}', force_tax_included BOOLEAN NOT NULL DEFAULT false, -- Distribución analítica analytic_distribution JSONB DEFAULT '{}', -- Auditoría created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_reconcile_model_lines_model ON treasury.reconcile_model_lines(model_id, sequence); ``` #### 2.2.5 treasury.reconcile_partner_mappings ```sql CREATE TABLE treasury.reconcile_partner_mappings ( id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), model_id UUID NOT NULL REFERENCES treasury.reconcile_models(id) ON DELETE CASCADE, -- Partner mapeado partner_id UUID NOT NULL REFERENCES core.partners(id), -- Patrones de detección (regex) payment_ref_regex VARCHAR(512), narration_regex VARCHAR(512), -- Al menos un patrón debe estar definido CONSTRAINT chk_at_least_one_pattern CHECK ( payment_ref_regex IS NOT NULL OR narration_regex IS NOT NULL ) ); CREATE INDEX idx_partner_mappings_model ON treasury.reconcile_partner_mappings(model_id); ``` #### 2.2.6 accounting.partial_reconciles ```sql CREATE TABLE accounting.partial_reconciles ( id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), -- Líneas conciliadas debit_move_line_id UUID NOT NULL REFERENCES accounting.move_lines(id), credit_move_line_id UUID NOT NULL REFERENCES accounting.move_lines(id), -- Grupo de conciliación completa full_reconcile_id UUID REFERENCES accounting.full_reconciles(id) ON DELETE SET NULL, -- Modelo que generó la conciliación reconcile_model_id UUID REFERENCES treasury.reconcile_models(id), -- Montos conciliados amount DECIMAL(20,6) NOT NULL, debit_amount_currency DECIMAL(20,6), credit_amount_currency DECIMAL(20,6), -- Fecha máxima de las líneas max_date DATE NOT NULL, -- Compañía company_id UUID NOT NULL REFERENCES core.companies(id), -- Asiento de diferencia cambiaria exchange_move_id UUID REFERENCES accounting.moves(id), -- Auditoría created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- Constraints CONSTRAINT chk_different_lines CHECK (debit_move_line_id != credit_move_line_id), CONSTRAINT chk_positive_amount CHECK (amount > 0) ); CREATE INDEX idx_partial_reconciles_debit ON accounting.partial_reconciles(debit_move_line_id); CREATE INDEX idx_partial_reconciles_credit ON accounting.partial_reconciles(credit_move_line_id); CREATE INDEX idx_partial_reconciles_full ON accounting.partial_reconciles(full_reconcile_id); ``` #### 2.2.7 accounting.full_reconciles ```sql CREATE TABLE accounting.full_reconciles ( id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), name VARCHAR(64) NOT NULL, -- Asiento de diferencia cambiaria (si aplica) exchange_move_id UUID REFERENCES accounting.moves(id), -- Auditoría created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE SEQUENCE accounting.full_reconcile_seq; -- Vista para líneas reconciliadas CREATE VIEW accounting.full_reconcile_lines AS SELECT fr.id AS full_reconcile_id, pr.debit_move_line_id AS move_line_id FROM accounting.full_reconciles fr JOIN accounting.partial_reconciles pr ON pr.full_reconcile_id = fr.id UNION SELECT fr.id AS full_reconcile_id, pr.credit_move_line_id AS move_line_id FROM accounting.full_reconciles fr JOIN accounting.partial_reconciles pr ON pr.full_reconcile_id = fr.id; ``` --- ## 3. Algoritmo de Matching Automático ### 3.1 Flujo Principal ``` ┌─────────────────────────────────────────────────────────────────┐ │ PROCESO DE CONCILIACIÓN │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────────────┐ │ │ │ Bank Statement │ │ │ │ Line Importada │ │ │ └────────┬─────────┘ │ │ │ │ │ ▼ │ │ ┌──────────────────┐ │ │ │ [1] Obtener │ │ │ │ Modelos Activos │◄─── ORDER BY sequence │ │ │ (Ordenados) │ │ │ └────────┬─────────┘ │ │ │ │ │ ▼ │ │ ┌──────────────────┐ NO │ │ │ [2] Evaluar │────────────┐ │ │ │ Condiciones │ │ │ │ │ del Modelo │ │ │ │ └────────┬─────────┘ │ │ │ │ SI │ │ │ ▼ │ │ │ ┌──────────────────────────┐ │ │ │ │ [3] Detectar Partner │ │ │ │ │ (Via Partner Mapping) │ │ │ │ └────────┬─────────────────┘ │ │ │ │ │ │ │ ▼ │ │ │ ┌────────────────────────────┐ │ │ │ │ [4] Según Tipo de Regla │ │ │ │ └────────┬───────────────────┘ │ │ │ │ │ │ │ ┌──────┴───────┐ │ │ │ ▼ ▼ │ │ │ invoice_ writeoff_ │ │ │ matching suggestion │ │ │ │ │ │ │ │ ▼ ▼ │ │ │ ┌─────────┐ ┌─────────────┐ │ │ │ │Buscar │ │Crear Líneas │ │ │ │ │Facturas │ │Contrapartida│ │ │ │ │Candidatas│ │(Write-off) │ │ │ │ └────┬────┘ └─────┬───────┘ │ │ │ │ │ │ │ │ ▼ │ │ │ │ ┌─────────┐ │ │ │ │ │Evaluar │ │ │ │ │ │Tolerancia│ │ │ │ │ └────┬────┘ │ │ │ │ │ │ │ │ │ └──────┬──────┘ │ │ │ ▼ │ │ │ ┌──────────────┐ │ │ │ │[5] Crear │ │ │ │ │Partial │ │ │ │ │Reconcile │ │ │ │ └──────┬───────┘ │ │ │ │ │ │ │ ▼ │ │ │ ┌──────────────┐ │ │ │ │auto_reconcile│ │ │ │ │ = true? │ │ │ │ └──────┬───────┘ │ │ │ │ SI │ │ │ ▼ │ │ │ ┌──────────────┐ │ │ │ │[6] Marcar │ │ │ │ │is_reconciled │ │ │ │ │= true │ │ │ │ └──────┬───────┘ │ │ │ │ │ │ │ ▼ │ │ │ ┌──────────────┐ │ │ │ │ FIN - Línea │◄────────────┘ │ │ │ Procesada │ (Siguiente modelo si no matcheó) │ │ └──────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────┘ ``` ### 3.2 Pseudocódigo del Motor de Matching ```python class BankReconciliationEngine: """ Motor de conciliación bancaria automática. Implementa el algoritmo de matching basado en reglas configurables. """ def reconcile_statement_lines( self, statement_lines: List[BankStatementLine] ) -> ReconciliationResult: """ Procesa múltiples líneas de extracto bancario. """ results = ReconciliationResult() for line in statement_lines: if line.is_reconciled: continue match_result = self._process_single_line(line) results.add(line.id, match_result) return results def _process_single_line( self, line: BankStatementLine ) -> Optional[MatchResult]: """ Procesa una línea individual aplicando modelos en secuencia. """ # Obtener modelos activos ordenados por secuencia models = self._get_active_models(line.company_id, line.journal_id) for model in models: # Fase 1: Evaluar todas las condiciones del modelo if not self._evaluate_conditions(line, model): continue # Fase 2: Detectar partner si no está definido partner = line.partner_id or self._detect_partner(line, model) # Fase 3: Aplicar según tipo de regla match model.rule_type: case 'invoice_matching': result = self._match_invoices(line, model, partner) case 'writeoff_suggestion' | 'writeoff_button': result = self._create_writeoff(line, model) case _: continue if result and result.matched: # Fase 4: Crear conciliaciones parciales self._create_partial_reconciles(line, result) # Fase 5: Auto-conciliar si está configurado if model.auto_reconcile and not model.to_check: self._finalize_reconciliation(line) return result return None # No match found def _evaluate_conditions( self, line: BankStatementLine, model: ReconcileModel ) -> bool: """ Evalúa todas las condiciones del modelo contra la línea. Todas las condiciones deben cumplirse (AND). """ conditions = model.conditions # 1. Verificar journals aplicables if conditions.match_journal_ids: if line.journal_id not in conditions.match_journal_ids: return False # 2. Verificar naturaleza (entrada/salida) if conditions.match_nature == 'amount_received': if line.amount <= 0: return False elif conditions.match_nature == 'amount_paid': if line.amount >= 0: return False # 3. Verificar rango de monto abs_amount = abs(line.amount) if conditions.match_amount == 'lower': if abs_amount > conditions.match_amount_min: return False elif conditions.match_amount == 'greater': if abs_amount < conditions.match_amount_min: return False elif conditions.match_amount == 'between': if not (conditions.match_amount_min <= abs_amount <= conditions.match_amount_max): return False # 4. Verificar patrones de texto en label if conditions.match_label: if not self._match_text( line.payment_ref or '', conditions.match_label_param, conditions.match_label ): return False # 5. Verificar patrones de texto en notas if conditions.match_note: narration = line.move_id.narration if line.move_id else '' if not self._match_text( narration or '', conditions.match_note_param, conditions.match_note ): return False # 6. Verificar tipo de transacción if conditions.match_transaction_type: if not self._match_text( line.transaction_type or '', conditions.match_transaction_type_param, conditions.match_transaction_type ): return False # 7. Verificar partner si es requerido if conditions.match_partner: partner = line.partner_id if conditions.match_partner_ids: if partner not in conditions.match_partner_ids: return False elif conditions.match_partner_category_ids: if not self._partner_in_categories( partner, conditions.match_partner_category_ids ): return False return True def _match_text( self, text: str, pattern: str, match_type: str ) -> bool: """ Evalúa coincidencia de texto según el tipo especificado. """ text_lower = text.lower() pattern_lower = pattern.lower() if pattern else '' match match_type: case 'contains': return pattern_lower in text_lower case 'not_contains': return pattern_lower not in text_lower case 'match_regex': import re try: return bool(re.search(pattern, text, re.IGNORECASE)) except re.error: return False case _: return True def _detect_partner( self, line: BankStatementLine, model: ReconcileModel ) -> Optional[UUID]: """ Detecta el partner basándose en los mappings de regex. """ for mapping in model.partner_mappings: # Verificar regex en payment_ref if mapping.payment_ref_regex: if self._match_text( line.payment_ref or '', mapping.payment_ref_regex, 'match_regex' ): return mapping.partner_id # Verificar regex en narration if mapping.narration_regex: narration = line.move_id.narration if line.move_id else '' if self._match_text( narration or '', mapping.narration_regex, 'match_regex' ): return mapping.partner_id return None def _match_invoices( self, line: BankStatementLine, model: ReconcileModel, partner_id: Optional[UUID] ) -> MatchResult: """ Busca facturas/pagos candidatos para matching. """ # Construir dominio de búsqueda conditions = model.conditions tolerance = model.tolerance # Calcular fecha límite histórica from datetime import date from dateutil.relativedelta import relativedelta cutoff_date = line.statement_id.date - relativedelta( months=conditions.past_months_limit ) # Query base: líneas sin conciliar en cuentas reconciliables candidates = self._search_candidates( company_id=line.company_id, cutoff_date=cutoff_date, partner_id=partner_id if conditions.match_partner else None, currency_id=line.currency_id if conditions.match_same_currency else None, nature='debit' if line.amount < 0 else 'credit', # Opuesto a la línea order='old_first' if model.matching_order == 'old_first' else 'new_first' ) matched_lines = [] remaining_amount = abs(line.amount) for candidate in candidates: # Verificar si el monto coincide (con tolerancia) match_info = self._check_amount_match( line_amount=remaining_amount, candidate_residual=abs(candidate.amount_residual), tolerance=tolerance ) if match_info.matches: matched_lines.append(MatchedLine( move_line_id=candidate.id, amount=match_info.matched_amount, tolerance_applied=match_info.tolerance_applied, tolerance_amount=match_info.tolerance_amount )) remaining_amount -= match_info.matched_amount if remaining_amount <= 0.01: # Fully matched break return MatchResult( matched=len(matched_lines) > 0, matched_lines=matched_lines, remaining_amount=remaining_amount, model_id=model.id ) def _check_amount_match( self, line_amount: Decimal, candidate_residual: Decimal, tolerance: ReconcileModelTolerance ) -> AmountMatchInfo: """ Verifica si los montos coinciden, aplicando tolerancia si está configurada. """ # Coincidencia exacta if abs(line_amount - candidate_residual) < Decimal('0.01'): return AmountMatchInfo( matches=True, matched_amount=candidate_residual, tolerance_applied=False, tolerance_amount=Decimal('0') ) # Coincidencia con tolerancia (para pagos parciales) if tolerance and tolerance.allow_payment_tolerance: difference = abs(line_amount - candidate_residual) if tolerance.payment_tolerance_type == 'percentage': threshold = line_amount * (tolerance.payment_tolerance_param / 100) else: # fixed_amount threshold = tolerance.payment_tolerance_param if difference <= threshold: # Matchear el monto menor matched = min(line_amount, candidate_residual) return AmountMatchInfo( matches=True, matched_amount=matched, tolerance_applied=True, tolerance_amount=difference ) # Sin match return AmountMatchInfo(matches=False) def _search_candidates( self, company_id: UUID, cutoff_date: date, partner_id: Optional[UUID], currency_id: Optional[UUID], nature: str, # 'debit' o 'credit' order: str ) -> List[MoveLine]: """ Busca líneas de movimiento candidatas para conciliación. """ query = """ SELECT ml.* FROM accounting.move_lines ml JOIN accounting.moves m ON ml.move_id = m.id JOIN accounting.accounts a ON ml.account_id = a.id WHERE ml.company_id = :company_id AND m.state = 'posted' AND m.date >= :cutoff_date AND ml.reconciled = false AND a.reconcile = true AND ml.display_type NOT IN ('line_section', 'line_note') -- Naturaleza opuesta (debit para cobros, credit para pagos) AND CASE WHEN :nature = 'debit' THEN ml.debit > 0 ELSE ml.credit > 0 END """ if partner_id: query += " AND ml.partner_id = :partner_id" if currency_id: query += " AND ml.currency_id = :currency_id" # Ordenar por fecha if order == 'old_first': query += " ORDER BY m.date ASC, ml.id ASC" else: query += " ORDER BY m.date DESC, ml.id DESC" return self.db.execute(query, locals()).fetchall() def _create_writeoff( self, line: BankStatementLine, model: ReconcileModel ) -> MatchResult: """ Crea líneas de contrapartida (write-off) según la configuración del modelo. """ writeoff_lines = [] remaining = abs(line.amount) for model_line in model.lines: # Calcular monto según el tipo amount = self._calculate_line_amount( line=line, model_line=model_line, remaining=remaining ) if abs(amount) < Decimal('0.01'): continue # Crear la línea de contrapartida writeoff_lines.append(WriteoffLine( account_id=model_line.account_id, amount=amount, label=model_line.label or line.payment_ref, tax_ids=model_line.tax_ids, force_tax_included=model_line.force_tax_included, analytic_distribution=model_line.analytic_distribution )) return MatchResult( matched=len(writeoff_lines) > 0, writeoff_lines=writeoff_lines, model_id=model.id ) def _calculate_line_amount( self, line: BankStatementLine, model_line: ReconcileModelLine, remaining: Decimal ) -> Decimal: """ Calcula el monto para una línea de write-off. """ match model_line.amount_type: case 'fixed': return Decimal(model_line.amount_string) case 'percentage': # Porcentaje del balance restante pct = Decimal(model_line.amount_string) / 100 return remaining * pct case 'percentage_st_line': # Porcentaje del monto original de la línea pct = Decimal(model_line.amount_string) / 100 return abs(line.amount) * pct case 'regex': # Extraer monto usando regex del payment_ref import re match = re.search(model_line.amount_string, line.payment_ref or '') if match: try: return Decimal(match.group(1).replace(',', '.')) except (ValueError, IndexError): pass return Decimal('0') case _: return Decimal('0') def _create_partial_reconciles( self, line: BankStatementLine, result: MatchResult ): """ Crea los registros de conciliación parcial. """ for matched in result.matched_lines: # Determinar cuál es debit y cuál credit if line.amount > 0: # Cobro: línea del extracto es credit, candidato es debit debit_line = matched.move_line_id credit_line = line.move_line_id # Línea creada en la cuenta de banco else: # Pago: línea del extracto es debit, candidato es credit debit_line = line.move_line_id credit_line = matched.move_line_id self._insert_partial_reconcile( debit_move_line_id=debit_line, credit_move_line_id=credit_line, amount=matched.amount, reconcile_model_id=result.model_id, max_date=max( line.statement_id.date, self._get_move_date(matched.move_line_id) ) ) # Si hay tolerancia, crear write-off if matched.tolerance_applied and matched.tolerance_amount > 0: self._create_tolerance_writeoff( line=line, tolerance_amount=matched.tolerance_amount, model=result.model ) def _finalize_reconciliation(self, line: BankStatementLine): """ Finaliza la conciliación de una línea. """ # Verificar si todas las partials suman al monto total total_reconciled = self._get_total_reconciled(line.move_line_id) if abs(total_reconciled - abs(line.amount)) < Decimal('0.01'): # Crear full_reconcile full_id = self._create_full_reconcile(line.move_line_id) # Marcar línea como reconciliada self._update_statement_line( line.id, is_reconciled=True, amount_residual=Decimal('0') ) def _get_active_models( self, company_id: UUID, journal_id: UUID ) -> List[ReconcileModel]: """ Obtiene los modelos de conciliación activos para el journal. """ query = """ SELECT rm.* FROM treasury.reconcile_models rm LEFT JOIN treasury.reconcile_model_conditions rmc ON rmc.model_id = rm.id WHERE rm.company_id = :company_id AND rm.is_active = true AND ( rmc.match_journal_ids IS NULL OR rmc.match_journal_ids = '{}' OR :journal_id = ANY(rmc.match_journal_ids) ) ORDER BY rm.sequence ASC """ return self.db.execute(query, locals()).fetchall() ``` --- ## 4. Importación de Extractos Bancarios ### 4.1 Formatos Soportados | Formato | Descripción | Región | Especificación | |---------|-------------|--------|----------------| | CAMT.053 | ISO 20022 Bank-to-Customer Statement | Global | ISO 20022 | | CAMT.054 | ISO 20022 Bank-to-Customer Debit/Credit Notification | Global | ISO 20022 | | OFX | Open Financial Exchange | USA/Global | OFX 2.2 | | QIF | Quicken Interchange Format | USA | Intuit | | MT940 | SWIFT Bank Statement | Europa | SWIFT | | CSV | Custom CSV format | Configurable | N/A | ### 4.2 Estructura de Parsing ```python from abc import ABC, abstractmethod from typing import List, Dict, Any from dataclasses import dataclass from decimal import Decimal from datetime import date import xml.etree.ElementTree as ET @dataclass class ParsedTransaction: """Transacción parseada de un extracto bancario.""" transaction_id: str date: date value_date: date amount: Decimal currency: str partner_name: Optional[str] partner_account: Optional[str] payment_ref: str transaction_type: str raw_details: Dict[str, Any] @dataclass class ParsedStatement: """Extracto bancario parseado.""" reference: str date: date balance_start: Decimal balance_end: Decimal currency: str account_number: str transactions: List[ParsedTransaction] class BankStatementParser(ABC): """Parser base para extractos bancarios.""" @abstractmethod def parse(self, content: bytes) -> List[ParsedStatement]: """Parsea el contenido del archivo.""" pass @abstractmethod def get_format_name(self) -> str: """Retorna el nombre del formato.""" pass class CAMT053Parser(BankStatementParser): """Parser para formato CAMT.053 (ISO 20022).""" NAMESPACES = { 'camt': 'urn:iso:std:iso:20022:tech:xsd:camt.053.001.02' } def get_format_name(self) -> str: return "CAMT.053" def parse(self, content: bytes) -> List[ParsedStatement]: root = ET.fromstring(content) statements = [] # Detectar namespace dinámicamente ns = self._detect_namespace(root) for stmt_elem in root.findall(f'.//{ns}Stmt'): stmt = self._parse_statement(stmt_elem, ns) statements.append(stmt) return statements def _detect_namespace(self, root: ET.Element) -> str: """Detecta el namespace del documento.""" tag = root.tag if '}' in tag: ns = tag.split('}')[0] + '}' return ns return '' def _parse_statement(self, elem: ET.Element, ns: str) -> ParsedStatement: """Parsea un statement individual.""" # Información básica id_elem = elem.find(f'{ns}Id') reference = id_elem.text if id_elem is not None else '' # Fechas stmt_date = self._parse_date(elem.find(f'{ns}CreDtTm')) # Saldos balances = elem.findall(f'{ns}Bal') balance_start = Decimal('0') balance_end = Decimal('0') for bal in balances: bal_type = bal.find(f'{ns}Tp/{ns}CdOrPrtry/{ns}Cd') if bal_type is not None: amt = self._parse_amount(bal.find(f'{ns}Amt')) cd_dt_ind = bal.find(f'{ns}CdtDbtInd') if cd_dt_ind is not None and cd_dt_ind.text == 'DBIT': amt = -amt if bal_type.text == 'OPBD': balance_start = amt elif bal_type.text == 'CLBD': balance_end = amt # Cuenta acct = elem.find(f'{ns}Acct/{ns}Id/{ns}IBAN') account_number = acct.text if acct is not None else '' # Moneda ccy = elem.find(f'{ns}Acct/{ns}Ccy') currency = ccy.text if ccy is not None else 'MXN' # Transacciones transactions = [] for entry in elem.findall(f'{ns}Ntry'): tx = self._parse_entry(entry, ns) if tx: transactions.append(tx) return ParsedStatement( reference=reference, date=stmt_date, balance_start=balance_start, balance_end=balance_end, currency=currency, account_number=account_number, transactions=transactions ) def _parse_entry(self, entry: ET.Element, ns: str) -> Optional[ParsedTransaction]: """Parsea una entrada de transacción.""" # Monto amt_elem = entry.find(f'{ns}Amt') amount = self._parse_amount(amt_elem) currency = amt_elem.get('Ccy') if amt_elem is not None else 'MXN' # Signo (Credit/Debit) cd_dt_ind = entry.find(f'{ns}CdtDbtInd') if cd_dt_ind is not None and cd_dt_ind.text == 'DBIT': amount = -amount # Fechas booking_date = self._parse_date(entry.find(f'{ns}BookgDt/{ns}Dt')) value_date = self._parse_date(entry.find(f'{ns}ValDt/{ns}Dt')) # Tipo de transacción tx_code = entry.find(f'{ns}BkTxCd/{ns}Prtry/{ns}Cd') tx_type = tx_code.text if tx_code is not None else '' # ID de transacción acct_svcr_ref = entry.find(f'{ns}AcctSvcrRef') tx_id = acct_svcr_ref.text if acct_svcr_ref is not None else str(uuid.uuid4()) # Información de contraparte party_elem = None details = entry.find(f'{ns}NtryDtls/{ns}TxDtls') if details is not None: # Para débitos, buscar acreedor; para créditos, buscar deudor if amount > 0: party_elem = details.find(f'{ns}RltdPties/{ns}Dbtr') else: party_elem = details.find(f'{ns}RltdPties/{ns}Cdtr') partner_name = None partner_account = None if party_elem is not None: name_elem = party_elem.find(f'{ns}Nm') partner_name = name_elem.text if name_elem is not None else None acct_elem = party_elem.find(f'{ns}Id/{ns}IBAN') partner_account = acct_elem.text if acct_elem is not None else None # Referencia de pago (remittance info) payment_ref = '' rmt_inf = entry.find(f'{ns}NtryDtls/{ns}TxDtls/{ns}RmtInf') if rmt_inf is not None: ustrd = rmt_inf.find(f'{ns}Ustrd') if ustrd is not None: payment_ref = ustrd.text or '' return ParsedTransaction( transaction_id=tx_id, date=booking_date or value_date, value_date=value_date or booking_date, amount=amount, currency=currency, partner_name=partner_name, partner_account=partner_account, payment_ref=payment_ref, transaction_type=tx_type, raw_details=self._extract_raw_details(entry, ns) ) def _parse_amount(self, elem: Optional[ET.Element]) -> Decimal: """Parsea un elemento de monto.""" if elem is None or elem.text is None: return Decimal('0') return Decimal(elem.text.replace(',', '.')) def _parse_date(self, elem: Optional[ET.Element]) -> Optional[date]: """Parsea un elemento de fecha.""" if elem is None or elem.text is None: return None try: return date.fromisoformat(elem.text[:10]) except ValueError: return None def _extract_raw_details(self, entry: ET.Element, ns: str) -> Dict[str, Any]: """Extrae detalles crudos para almacenar en JSON.""" # Convertir XML a dict para almacenar return {'xml': ET.tostring(entry, encoding='unicode')} class OFXParser(BankStatementParser): """Parser para formato OFX (Open Financial Exchange).""" def get_format_name(self) -> str: return "OFX" def parse(self, content: bytes) -> List[ParsedStatement]: # Limpiar el header SGML de OFX 1.x si existe content_str = content.decode('utf-8', errors='replace') content_str = self._convert_to_xml(content_str) root = ET.fromstring(content_str) statements = [] for stmt_rs in root.findall('.//STMTRS') + root.findall('.//CCSTMTRS'): stmt = self._parse_statement(stmt_rs) statements.append(stmt) return statements def _convert_to_xml(self, content: str) -> str: """Convierte OFX SGML a XML válido.""" import re # Remover header OFX content = re.sub(r'^.*?', '', content, flags=re.DOTALL) # Cerrar tags sin cerrar content = re.sub(r'<(\w+)>([^<]*)\n', r'<\1>\2\n', content) return content def _parse_statement(self, stmt_rs: ET.Element) -> ParsedStatement: """Parsea un statement OFX.""" # Cuenta acct_from = stmt_rs.find('BANKACCTFROM') or stmt_rs.find('CCACCTFROM') account_number = '' if acct_from is not None: acct_id = acct_from.find('ACCTID') account_number = acct_id.text if acct_id is not None else '' # Moneda currency = 'USD' # Default OFX ccy_elem = stmt_rs.find('CURDEF') if ccy_elem is not None: currency = ccy_elem.text # Saldos ledger_bal = stmt_rs.find('LEDGERBAL') balance_end = Decimal('0') stmt_date = date.today() if ledger_bal is not None: bal_amt = ledger_bal.find('BALAMT') if bal_amt is not None: balance_end = Decimal(bal_amt.text) dt_as_of = ledger_bal.find('DTASOF') if dt_as_of is not None: stmt_date = self._parse_ofx_date(dt_as_of.text) # Transacciones transactions = [] bank_trans_list = stmt_rs.find('BANKTRANLIST') or stmt_rs.find('CCSTMTTRNRS') if bank_trans_list is not None: for stmttrn in bank_trans_list.findall('STMTTRN'): tx = self._parse_transaction(stmttrn) if tx: transactions.append(tx) # Calcular balance inicial tx_total = sum(tx.amount for tx in transactions) balance_start = balance_end - tx_total return ParsedStatement( reference=f"OFX-{stmt_date.isoformat()}", date=stmt_date, balance_start=balance_start, balance_end=balance_end, currency=currency, account_number=account_number, transactions=transactions ) def _parse_transaction(self, stmttrn: ET.Element) -> Optional[ParsedTransaction]: """Parsea una transacción OFX.""" # Tipo trntype = stmttrn.find('TRNTYPE') tx_type = trntype.text if trntype is not None else 'OTHER' # Monto trnamt = stmttrn.find('TRNAMT') amount = Decimal(trnamt.text) if trnamt is not None else Decimal('0') # Fecha dtposted = stmttrn.find('DTPOSTED') tx_date = self._parse_ofx_date( dtposted.text if dtposted is not None else None ) # ID fitid = stmttrn.find('FITID') tx_id = fitid.text if fitid is not None else str(uuid.uuid4()) # Nombre/Referencia name = stmttrn.find('NAME') memo = stmttrn.find('MEMO') partner_name = name.text if name is not None else None payment_ref = memo.text if memo is not None else ( name.text if name is not None else '' ) return ParsedTransaction( transaction_id=tx_id, date=tx_date, value_date=tx_date, amount=amount, currency='USD', partner_name=partner_name, partner_account=None, payment_ref=payment_ref, transaction_type=tx_type, raw_details={'ofx_type': tx_type} ) def _parse_ofx_date(self, date_str: Optional[str]) -> date: """Parsea fecha OFX (YYYYMMDD[HHmmss]).""" if not date_str: return date.today() try: return date( int(date_str[0:4]), int(date_str[4:6]), int(date_str[6:8]) ) except (ValueError, IndexError): return date.today() class BankStatementImportService: """Servicio de importación de extractos bancarios.""" PARSERS = { 'camt053': CAMT053Parser, 'ofx': OFXParser, # 'mt940': MT940Parser, # 'csv': CSVParser, } def import_statement( self, journal_id: UUID, file_content: bytes, file_name: str, format_hint: Optional[str] = None ) -> List[BankStatement]: """ Importa un extracto bancario. Args: journal_id: ID del diario bancario file_content: Contenido del archivo file_name: Nombre del archivo format_hint: Formato sugerido (opcional) Returns: Lista de extractos creados """ # Detectar formato format_type = format_hint or self._detect_format(file_content, file_name) # Obtener parser parser_class = self.PARSERS.get(format_type) if not parser_class: raise ValueError(f"Formato no soportado: {format_type}") parser = parser_class() # Parsear archivo parsed_statements = parser.parse(file_content) # Crear extractos en la base de datos created_statements = [] for parsed in parsed_statements: stmt = self._create_statement(journal_id, parsed, file_name) created_statements.append(stmt) return created_statements def _detect_format(self, content: bytes, filename: str) -> str: """Detecta el formato del archivo.""" filename_lower = filename.lower() # Por extensión if filename_lower.endswith('.xml'): # Verificar si es CAMT if b'camt.053' in content or b'BkToCstmrStmt' in content: return 'camt053' if filename_lower.endswith('.ofx') or filename_lower.endswith('.qfx'): return 'ofx' if filename_lower.endswith('.sta') or filename_lower.endswith('.mt940'): return 'mt940' if filename_lower.endswith('.csv'): return 'csv' # Por contenido content_start = content[:500].decode('utf-8', errors='replace') if 'OFXHEADER' in content_start or '' in content_start: return 'ofx' if 'camt.053' in content_start or 'BkToCstmrStmt' in content_start: return 'camt053' if ':20:' in content_start and ':25:' in content_start: return 'mt940' raise ValueError("No se pudo detectar el formato del archivo") def _create_statement( self, journal_id: UUID, parsed: ParsedStatement, file_name: str ) -> BankStatement: """Crea un extracto bancario a partir de los datos parseados.""" # Obtener journal y compañía journal = self.journal_repo.get(journal_id) # Crear statement statement = BankStatement( name=self._generate_statement_name(journal, parsed.date), reference=parsed.reference or file_name, journal_id=journal_id, company_id=journal.company_id, currency_id=journal.currency_id, date=parsed.date, balance_start=parsed.balance_start, balance_end_real=parsed.balance_end ) self.statement_repo.create(statement) # Crear líneas for idx, tx in enumerate(parsed.transactions): line = self._create_statement_line( statement=statement, transaction=tx, sequence=idx * 10 ) self.line_repo.create(line) # Calcular índices internos self._compute_internal_indexes(statement.id) return statement ``` --- ## 5. API REST ### 5.1 Endpoints ```yaml openapi: 3.0.3 info: title: Bank Reconciliation API version: 1.0.0 paths: /api/v1/treasury/bank-statements: get: summary: Listar extractos bancarios parameters: - name: journal_id in: query schema: type: string format: uuid - name: date_from in: query schema: type: string format: date - name: date_to in: query schema: type: string format: date - name: is_complete in: query schema: type: boolean responses: '200': description: Lista de extractos content: application/json: schema: type: array items: $ref: '#/components/schemas/BankStatement' post: summary: Importar extracto bancario requestBody: content: multipart/form-data: schema: type: object properties: journal_id: type: string format: uuid file: type: string format: binary format: type: string enum: [auto, camt053, ofx, mt940, csv] required: - journal_id - file responses: '201': description: Extracto(s) creado(s) content: application/json: schema: type: object properties: statements: type: array items: $ref: '#/components/schemas/BankStatement' line_count: type: integer auto_reconciled_count: type: integer /api/v1/treasury/bank-statements/{id}: get: summary: Obtener extracto con líneas parameters: - name: id in: path required: true schema: type: string format: uuid responses: '200': description: Extracto con líneas content: application/json: schema: $ref: '#/components/schemas/BankStatementDetail' /api/v1/treasury/bank-statement-lines: get: summary: Listar líneas de extracto parameters: - name: statement_id in: query schema: type: string format: uuid - name: journal_id in: query schema: type: string format: uuid - name: is_reconciled in: query schema: type: boolean - name: date_from in: query schema: type: string format: date - name: date_to in: query schema: type: string format: date responses: '200': description: Lista de líneas content: application/json: schema: type: array items: $ref: '#/components/schemas/BankStatementLine' /api/v1/treasury/bank-statement-lines/{id}/reconcile: post: summary: Conciliar línea manualmente parameters: - name: id in: path required: true schema: type: string format: uuid requestBody: content: application/json: schema: type: object properties: move_line_ids: type: array items: type: string format: uuid description: Líneas de movimiento a conciliar writeoff_lines: type: array items: $ref: '#/components/schemas/WriteoffLine' description: Líneas de diferencia (write-off) responses: '200': description: Conciliación exitosa content: application/json: schema: type: object properties: partial_reconcile_ids: type: array items: type: string format: uuid full_reconcile_id: type: string format: uuid /api/v1/treasury/bank-statement-lines/{id}/undo-reconcile: post: summary: Deshacer conciliación parameters: - name: id in: path required: true schema: type: string format: uuid responses: '200': description: Conciliación deshecha content: application/json: schema: type: object properties: message: type: string /api/v1/treasury/bank-statement-lines/{id}/matching-candidates: get: summary: Obtener candidatos para matching parameters: - name: id in: path required: true schema: type: string format: uuid - name: limit in: query schema: type: integer default: 20 responses: '200': description: Candidatos encontrados content: application/json: schema: type: object properties: candidates: type: array items: $ref: '#/components/schemas/MatchingCandidate' applied_models: type: array items: type: object properties: model_id: type: string format: uuid model_name: type: string matched: type: boolean /api/v1/treasury/reconcile-models: get: summary: Listar modelos de conciliación responses: '200': description: Lista de modelos content: application/json: schema: type: array items: $ref: '#/components/schemas/ReconcileModel' post: summary: Crear modelo de conciliación requestBody: content: application/json: schema: $ref: '#/components/schemas/ReconcileModelCreate' responses: '201': description: Modelo creado content: application/json: schema: $ref: '#/components/schemas/ReconcileModel' /api/v1/treasury/reconcile-models/{id}: get: summary: Obtener modelo responses: '200': description: Modelo content: application/json: schema: $ref: '#/components/schemas/ReconcileModel' put: summary: Actualizar modelo requestBody: content: application/json: schema: $ref: '#/components/schemas/ReconcileModelUpdate' responses: '200': description: Modelo actualizado delete: summary: Eliminar modelo responses: '204': description: Modelo eliminado /api/v1/treasury/auto-reconcile: post: summary: Ejecutar conciliación automática requestBody: content: application/json: schema: type: object properties: journal_ids: type: array items: type: string format: uuid description: Journals a procesar (vacío = todos) statement_ids: type: array items: type: string format: uuid description: Extractos específicos (opcional) date_from: type: string format: date date_to: type: string format: date responses: '200': description: Resultado de conciliación automática content: application/json: schema: type: object properties: processed_lines: type: integer reconciled_lines: type: integer failed_lines: type: integer details: type: array items: type: object properties: line_id: type: string format: uuid status: type: string enum: [reconciled, no_match, error] model_applied: type: string components: schemas: BankStatement: type: object properties: id: type: string format: uuid name: type: string reference: type: string journal_id: type: string format: uuid journal_name: type: string date: type: string format: date balance_start: type: number balance_end: type: number balance_end_real: type: number is_complete: type: boolean is_valid: type: boolean line_count: type: integer reconciled_count: type: integer BankStatementDetail: allOf: - $ref: '#/components/schemas/BankStatement' - type: object properties: lines: type: array items: $ref: '#/components/schemas/BankStatementLine' BankStatementLine: type: object properties: id: type: string format: uuid statement_id: type: string format: uuid sequence: type: integer date: type: string format: date payment_ref: type: string partner_id: type: string format: uuid partner_name: type: string amount: type: number amount_currency: type: number foreign_currency_id: type: string format: uuid running_balance: type: number transaction_type: type: string is_reconciled: type: boolean amount_residual: type: number matching_number: type: string description: "ID de full_reconcile o 'P' + partial_id" MatchingCandidate: type: object properties: move_line_id: type: string format: uuid move_id: type: string format: uuid move_name: type: string date: type: string format: date partner_id: type: string format: uuid partner_name: type: string account_id: type: string format: uuid account_name: type: string amount_residual: type: number currency_id: type: string format: uuid reference: type: string match_score: type: number description: Puntuación de coincidencia (0-100) match_reasons: type: array items: type: string description: Razones del match ReconcileModel: type: object properties: id: type: string format: uuid name: type: string sequence: type: integer rule_type: type: string enum: [writeoff_button, writeoff_suggestion, invoice_matching] auto_reconcile: type: boolean to_check: type: boolean matching_order: type: string enum: [old_first, new_first] is_active: type: boolean conditions: $ref: '#/components/schemas/ReconcileModelConditions' tolerance: $ref: '#/components/schemas/ReconcileModelTolerance' lines: type: array items: $ref: '#/components/schemas/ReconcileModelLine' partner_mappings: type: array items: $ref: '#/components/schemas/ReconcilePartnerMapping' ReconcileModelConditions: type: object properties: match_journal_ids: type: array items: type: string format: uuid match_nature: type: string enum: [amount_received, amount_paid, both] match_amount: type: string enum: [lower, greater, between] match_amount_min: type: number match_amount_max: type: number match_same_currency: type: boolean match_label: type: string enum: [contains, not_contains, match_regex] match_label_param: type: string match_partner: type: boolean match_partner_ids: type: array items: type: string format: uuid past_months_limit: type: integer ReconcileModelTolerance: type: object properties: allow_payment_tolerance: type: boolean payment_tolerance_type: type: string enum: [percentage, fixed_amount] payment_tolerance_param: type: number tolerance_account_id: type: string format: uuid ReconcileModelLine: type: object properties: id: type: string format: uuid sequence: type: integer account_id: type: string format: uuid amount_type: type: string enum: [fixed, percentage, percentage_st_line, regex] amount_string: type: string label: type: string tax_ids: type: array items: type: string format: uuid ReconcilePartnerMapping: type: object properties: id: type: string format: uuid partner_id: type: string format: uuid partner_name: type: string payment_ref_regex: type: string narration_regex: type: string WriteoffLine: type: object properties: account_id: type: string format: uuid amount: type: number label: type: string tax_ids: type: array items: type: string format: uuid ``` --- ## 6. Ejemplos de Configuración ### 6.1 Modelo: Comisiones Bancarias ```json { "name": "Comisiones Bancarias", "sequence": 10, "rule_type": "writeoff_suggestion", "auto_reconcile": true, "to_check": false, "conditions": { "match_nature": "amount_paid", "match_label": "match_regex", "match_label_param": "(?i)(comisi[óo]n|cargo|fee|charge)", "match_amount": "lower", "match_amount_min": 1000.00 }, "lines": [ { "sequence": 10, "account_id": "uuid-cuenta-gastos-bancarios", "amount_type": "percentage", "amount_string": "100", "label": "Comisión bancaria" } ] } ``` ### 6.2 Modelo: Matching de Facturas con Tolerancia ```json { "name": "Matching Facturas Clientes", "sequence": 20, "rule_type": "invoice_matching", "auto_reconcile": true, "to_check": false, "matching_order": "old_first", "conditions": { "match_nature": "amount_received", "match_partner": true, "match_same_currency": true, "past_months_limit": 12 }, "tolerance": { "allow_payment_tolerance": true, "payment_tolerance_type": "percentage", "payment_tolerance_param": 2.00, "tolerance_account_id": "uuid-cuenta-descuentos" } } ``` ### 6.3 Modelo: Pagos a Proveedores con Detección de Partner ```json { "name": "Pagos a Proveedores", "sequence": 30, "rule_type": "invoice_matching", "auto_reconcile": false, "to_check": true, "matching_order": "new_first", "conditions": { "match_nature": "amount_paid", "match_partner": true, "past_months_limit": 6 }, "partner_mappings": [ { "partner_id": "uuid-proveedor-1", "payment_ref_regex": "(?i)ACME|ACM\\d+" }, { "partner_id": "uuid-proveedor-2", "payment_ref_regex": "(?i)SUPPLIER-\\d+" } ] } ``` ### 6.4 Modelo: IVA Retenido (Extracción por Regex) ```json { "name": "IVA Retenido en Pagos", "sequence": 40, "rule_type": "writeoff_suggestion", "auto_reconcile": false, "to_check": true, "conditions": { "match_nature": "amount_received", "match_label": "match_regex", "match_label_param": "RET\\.?\\s*IVA[:\\s]*(\\d+[\\.\\,]?\\d*)" }, "lines": [ { "sequence": 10, "account_id": "uuid-cuenta-iva-retenido", "amount_type": "regex", "amount_string": "RET\\.?\\s*IVA[:\\s]*(\\d+[\\.\\,]?\\d*)", "label": "IVA Retenido" } ] } ``` --- ## 7. Índices de Base de Datos Optimizados ```sql -- ===================================================== -- ÍNDICES PARA RENDIMIENTO DE CONCILIACIÓN -- ===================================================== -- Búsqueda de líneas no reconciliadas por journal CREATE INDEX idx_bsl_unreconciled_journal ON treasury.bank_statement_lines(journal_id, internal_index) WHERE NOT is_reconciled AND state = 'posted'; -- Búsqueda de candidatos para matching CREATE INDEX idx_move_lines_reconcile_candidates ON accounting.move_lines(account_id, company_id, partner_id) WHERE reconciled = false AND parent_state = 'posted'; -- Búsqueda por monto residual CREATE INDEX idx_move_lines_amount_residual ON accounting.move_lines(ABS(amount_residual)) WHERE reconciled = false AND parent_state = 'posted'; -- Búsqueda full-text en payment_ref CREATE INDEX idx_bsl_payment_ref_trgm ON treasury.bank_statement_lines USING GIN (payment_ref gin_trgm_ops); -- Modelos activos por compañía CREATE INDEX idx_reconcile_models_active_company ON treasury.reconcile_models(company_id, sequence) WHERE is_active = true; -- Conciliaciones parciales por línea CREATE INDEX idx_partial_reconciles_lines ON accounting.partial_reconciles(debit_move_line_id, credit_move_line_id); -- Balance acumulado (running balance) CREATE INDEX idx_bsl_running_balance ON treasury.bank_statement_lines(statement_id, internal_index) INCLUDE (amount, running_balance); ``` --- ## 8. Reglas de Negocio ### 8.1 Validaciones | Regla | Descripción | Acción | |-------|-------------|--------| | RN-001 | El saldo inicial debe coincidir con el final del extracto anterior | Warning si no coincide | | RN-002 | El saldo calculado debe coincidir con el saldo real | Marcar `is_complete = false` | | RN-003 | No se puede conciliar una línea ya reconciliada | Error | | RN-004 | Las líneas de conciliación deben tener signos opuestos | Error | | RN-005 | La suma de parciales no puede exceder el monto de la línea | Error | | RN-006 | Regex de partner mapping debe ser válido | Error al guardar | | RN-007 | Tolerancia porcentual debe estar entre 0 y 100 | Error | | RN-008 | Journal debe ser tipo 'bank' o 'cash' | Error | ### 8.2 Comportamientos Automáticos | Trigger | Acción | |---------|--------| | Crear línea de extracto | Generar `internal_index` | | Crear línea de extracto | Calcular `running_balance` | | Crear `partial_reconcile` | Actualizar `amount_residual` en move_lines | | Todos los parciales suman al total | Crear `full_reconcile` | | Eliminar `partial_reconcile` | Recalcular `amount_residual` | | Eliminar `full_reconcile` | Eliminar exchange_move asociado | | Importar extracto | Ejecutar matching automático | --- ## 9. Consideraciones de Rendimiento ### 9.1 Estrategias de Optimización 1. **Batch Processing** - Procesar líneas en lotes de 100 - Usar transacciones para atomicidad - Evitar queries N+1 2. **Caching** - Cache de modelos de conciliación por company - Cache de patrones regex compilados - Cache de cuentas reconciliables 3. **Query Optimization** - Usar CTEs para candidatos - Limitar búsqueda histórica (past_months_limit) - Índices parciales para líneas no reconciliadas 4. **Async Processing** - Importación de extractos en background - Reconciliación masiva en job separado - Notificación al completar ### 9.2 Límites Recomendados | Operación | Límite | Razón | |-----------|--------|-------| | Líneas por importación | 10,000 | Memoria/tiempo | | Candidatos por búsqueda | 100 | UI responsiva | | Modelos por compañía | 50 | Evaluación secuencial | | Partner mappings por modelo | 100 | Regex evaluation | | Meses históricos máximo | 36 | Tamaño de búsqueda | --- ## 10. Integración con Otros Módulos ### 10.1 Contabilidad (accounting) - Creación de asientos automáticos - Conciliación de move_lines - Diferencias cambiarias ### 10.2 Pagos (payments) - Sincronización de pagos con líneas bancarias - Detección de pagos pendientes - Matching por referencia de pago ### 10.3 Facturación (invoicing) - Matching de facturas/notas de crédito - Actualización de estado de pago - Alertas de facturas vencidas ### 10.4 Partners (core) - Detección automática de partners - Creación de cuentas bancarias - Historial de transacciones --- ## 11. Anexos ### A. Migración desde Sistema Anterior ```sql -- Migrar extractos existentes INSERT INTO treasury.bank_statements (...) SELECT ... FROM legacy.bank_statements; -- Recalcular índices internos SELECT treasury.recompute_statement_indexes(); ``` ### B. Scripts de Mantenimiento ```sql -- Limpiar conciliaciones huérfanas DELETE FROM accounting.partial_reconciles WHERE debit_move_line_id NOT IN (SELECT id FROM accounting.move_lines) OR credit_move_line_id NOT IN (SELECT id FROM accounting.move_lines); -- Recalcular montos residuales UPDATE accounting.move_lines ml SET amount_residual = ml.balance - COALESCE(( SELECT SUM(amount) FROM accounting.partial_reconciles pr WHERE pr.debit_move_line_id = ml.id OR pr.credit_move_line_id = ml.id ), 0); ``` --- ## 12. Referencias - [ISO 20022 CAMT.053 Specification](https://www.iso20022.org/iso-20022-message-definitions) - [OFX 2.2 Specification](https://www.ofx.net/downloads.html) - [SWIFT MT940 Format](https://www.swift.com/) - [Odoo Bank Reconciliation](https://www.odoo.com/documentation/17.0/applications/finance/accounting/bank.html) --- **Documento generado para ERP-SUITE** **Versión**: 1.0 **Fecha**: 2025-01-09