erp-core/docs/04-modelado/especificaciones-tecnicas/transversal/SPEC-CONCILIACION-BANCARIA.md

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

  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

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