81 KiB
81 KiB
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
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
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
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
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
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
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
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
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
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'^.*?<OFX>', '<OFX>', content, flags=re.DOTALL)
# Cerrar tags sin cerrar
content = re.sub(r'<(\w+)>([^<]*)\n', r'<\1>\2</\1>\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 '<OFX>' 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
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
{
"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
{
"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
{
"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)
{
"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
-- =====================================================
-- Í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
-
Batch Processing
- Procesar líneas en lotes de 100
- Usar transacciones para atomicidad
- Evitar queries N+1
-
Caching
- Cache de modelos de conciliación por company
- Cache de patrones regex compilados
- Cache de cuentas reconciliables
-
Query Optimization
- Usar CTEs para candidatos
- Limitar búsqueda histórica (past_months_limit)
- Índices parciales para líneas no reconciliadas
-
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
-- 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
-- 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
Documento generado para ERP-SUITE Versión: 1.0 Fecha: 2025-01-09