# 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'^.*?<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

```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
