From 4b6240311dc5dbaaa66dd05072250e97f89ca39b Mon Sep 17 00:00:00 2001 From: rckrdmrd Date: Tue, 20 Jan 2026 03:47:04 -0600 Subject: [PATCH] [TASK-2026-01-20-003] feat: Add financial DDL and matching tables Financial Schema (50-57): - 50-financial-schema.sql: Schema + 10 ENUMs - 51-financial-accounts.sql: account_types, accounts, account_mappings - 52-financial-journals.sql: fiscal_years, fiscal_periods, journals - 53-financial-entries.sql: journal_entries, journal_entry_lines - 54-financial-invoices.sql: invoices, invoice_lines - 55-financial-payments.sql: payments, payment_invoice_allocations - 56-financial-taxes.sql: taxes, tax_groups - 57-financial-bank-reconciliation.sql: bank_statements, bank_statement_lines, rules Purchases Matching (46): - 46-purchases-matching.sql: purchase_order_matching, purchase_matching_lines, matching_exceptions Co-Authored-By: Claude Opus 4.5 --- ddl/46-purchases-matching.sql | 140 +++++++++++++ ddl/50-financial-schema.sql | 154 ++++++++++++++ ddl/51-financial-accounts.sql | 143 +++++++++++++ ddl/52-financial-journals.sql | 162 +++++++++++++++ ddl/53-financial-entries.sql | 175 ++++++++++++++++ ddl/54-financial-invoices.sql | 167 +++++++++++++++ ddl/55-financial-payments.sql | 174 ++++++++++++++++ ddl/56-financial-taxes.sql | 155 ++++++++++++++ ddl/57-financial-bank-reconciliation.sql | 254 +++++++++++++++++++++++ 9 files changed, 1524 insertions(+) create mode 100644 ddl/46-purchases-matching.sql create mode 100644 ddl/50-financial-schema.sql create mode 100644 ddl/51-financial-accounts.sql create mode 100644 ddl/52-financial-journals.sql create mode 100644 ddl/53-financial-entries.sql create mode 100644 ddl/54-financial-invoices.sql create mode 100644 ddl/55-financial-payments.sql create mode 100644 ddl/56-financial-taxes.sql create mode 100644 ddl/57-financial-bank-reconciliation.sql diff --git a/ddl/46-purchases-matching.sql b/ddl/46-purchases-matching.sql new file mode 100644 index 0000000..a11a732 --- /dev/null +++ b/ddl/46-purchases-matching.sql @@ -0,0 +1,140 @@ +-- ============================================================= +-- ARCHIVO: 46-purchases-matching.sql +-- DESCRIPCION: 3-Way Matching (PO-Receipt-Invoice) +-- VERSION: 1.0.0 +-- PROYECTO: ERP-Core V2 +-- FECHA: 2026-01-20 +-- DEPENDE DE: 23-purchases.sql +-- ============================================================= + +-- ===================== +-- TABLA: purchase_order_matching +-- Registro de matching por orden de compra +-- ===================== +CREATE TABLE IF NOT EXISTS purchases.purchase_order_matching ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + purchase_order_id UUID NOT NULL REFERENCES purchases.purchase_orders(id) ON DELETE RESTRICT, + + -- Estado del matching + status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending, partial_receipt, received, partial_invoice, matched, mismatch + + -- Totales + total_ordered DECIMAL(15, 2) NOT NULL, + total_received DECIMAL(15, 2) DEFAULT 0, + total_invoiced DECIMAL(15, 2) DEFAULT 0, + + -- Varianzas calculadas + receipt_variance DECIMAL(15, 2) GENERATED ALWAYS AS (total_ordered - total_received) STORED, + invoice_variance DECIMAL(15, 2) GENERATED ALWAYS AS (total_received - total_invoiced) STORED, + + -- Referencias al ultimo documento + last_receipt_id UUID REFERENCES purchases.purchase_receipts(id), + last_invoice_id UUID, + + -- Matching completado + matched_at TIMESTAMPTZ, + matched_by UUID REFERENCES auth.users(id), + + -- Metadata + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + + UNIQUE(tenant_id, purchase_order_id) +); + +-- Indices para purchase_order_matching +CREATE INDEX IF NOT EXISTS idx_purchase_order_matching_tenant ON purchases.purchase_order_matching(tenant_id); +CREATE INDEX IF NOT EXISTS idx_purchase_order_matching_po ON purchases.purchase_order_matching(purchase_order_id); +CREATE INDEX IF NOT EXISTS idx_purchase_order_matching_status ON purchases.purchase_order_matching(status); +CREATE INDEX IF NOT EXISTS idx_purchase_order_matching_receipt ON purchases.purchase_order_matching(last_receipt_id); + +-- ===================== +-- TABLA: purchase_matching_lines +-- Matching por linea de orden de compra +-- ===================== +CREATE TABLE IF NOT EXISTS purchases.purchase_matching_lines ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + matching_id UUID NOT NULL REFERENCES purchases.purchase_order_matching(id) ON DELETE CASCADE, + order_item_id UUID NOT NULL REFERENCES purchases.purchase_order_items(id) ON DELETE RESTRICT, + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + + -- Cantidades + qty_ordered DECIMAL(15, 4) NOT NULL, + qty_received DECIMAL(15, 4) DEFAULT 0, + qty_invoiced DECIMAL(15, 4) DEFAULT 0, + + -- Precios + price_ordered DECIMAL(15, 2) NOT NULL, + price_invoiced DECIMAL(15, 2) DEFAULT 0, + + -- Varianzas calculadas + qty_variance DECIMAL(15, 4) GENERATED ALWAYS AS (qty_ordered - qty_received) STORED, + invoice_qty_variance DECIMAL(15, 4) GENERATED ALWAYS AS (qty_received - qty_invoiced) STORED, + price_variance DECIMAL(15, 2) GENERATED ALWAYS AS (price_ordered - price_invoiced) STORED, + + -- Estado + status VARCHAR(20) DEFAULT 'pending', -- pending, partial, matched, mismatch + + -- Metadata + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +-- Indices para purchase_matching_lines +CREATE INDEX IF NOT EXISTS idx_purchase_matching_lines_matching ON purchases.purchase_matching_lines(matching_id); +CREATE INDEX IF NOT EXISTS idx_purchase_matching_lines_order_item ON purchases.purchase_matching_lines(order_item_id); +CREATE INDEX IF NOT EXISTS idx_purchase_matching_lines_tenant ON purchases.purchase_matching_lines(tenant_id); +CREATE INDEX IF NOT EXISTS idx_purchase_matching_lines_status ON purchases.purchase_matching_lines(status); + +-- ===================== +-- TABLA: matching_exceptions +-- Excepciones de matching +-- ===================== +CREATE TABLE IF NOT EXISTS purchases.matching_exceptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + matching_id UUID REFERENCES purchases.purchase_order_matching(id) ON DELETE CASCADE, + matching_line_id UUID REFERENCES purchases.purchase_matching_lines(id) ON DELETE CASCADE, + + -- Tipo de excepcion + exception_type VARCHAR(50) NOT NULL, -- over_receipt, short_receipt, over_invoice, short_invoice, price_variance + + -- Valores + expected_value DECIMAL(15, 4), + actual_value DECIMAL(15, 4), + variance_value DECIMAL(15, 4), + variance_percent DECIMAL(5, 2), + + -- Resolucion + status VARCHAR(20) DEFAULT 'pending', -- pending, approved, rejected + resolved_at TIMESTAMPTZ, + resolved_by UUID REFERENCES auth.users(id), + resolution_notes TEXT, + + -- Metadata + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +-- Indices para matching_exceptions +CREATE INDEX IF NOT EXISTS idx_matching_exceptions_tenant ON purchases.matching_exceptions(tenant_id); +CREATE INDEX IF NOT EXISTS idx_matching_exceptions_matching ON purchases.matching_exceptions(matching_id); +CREATE INDEX IF NOT EXISTS idx_matching_exceptions_line ON purchases.matching_exceptions(matching_line_id); +CREATE INDEX IF NOT EXISTS idx_matching_exceptions_type ON purchases.matching_exceptions(exception_type); +CREATE INDEX IF NOT EXISTS idx_matching_exceptions_status ON purchases.matching_exceptions(status); + +-- ===================== +-- COMENTARIOS +-- ===================== +COMMENT ON TABLE purchases.purchase_order_matching IS 'Registro de 3-way matching por orden de compra (PO-Receipt-Invoice)'; +COMMENT ON COLUMN purchases.purchase_order_matching.status IS 'Estado: pending, partial_receipt, received, partial_invoice, matched, mismatch'; +COMMENT ON COLUMN purchases.purchase_order_matching.receipt_variance IS 'Varianza = total_ordered - total_received (columna generada)'; +COMMENT ON COLUMN purchases.purchase_order_matching.invoice_variance IS 'Varianza = total_received - total_invoiced (columna generada)'; + +COMMENT ON TABLE purchases.purchase_matching_lines IS 'Matching por linea de detalle de orden de compra'; +COMMENT ON COLUMN purchases.purchase_matching_lines.qty_variance IS 'Varianza de cantidad = qty_ordered - qty_received (columna generada)'; +COMMENT ON COLUMN purchases.purchase_matching_lines.invoice_qty_variance IS 'Varianza de factura = qty_received - qty_invoiced (columna generada)'; +COMMENT ON COLUMN purchases.purchase_matching_lines.price_variance IS 'Varianza de precio = price_ordered - price_invoiced (columna generada)'; + +COMMENT ON TABLE purchases.matching_exceptions IS 'Excepciones detectadas durante el proceso de matching'; +COMMENT ON COLUMN purchases.matching_exceptions.exception_type IS 'Tipo: over_receipt, short_receipt, over_invoice, short_invoice, price_variance'; +COMMENT ON COLUMN purchases.matching_exceptions.status IS 'Estado: pending, approved, rejected'; diff --git a/ddl/50-financial-schema.sql b/ddl/50-financial-schema.sql new file mode 100644 index 0000000..d04ec0a --- /dev/null +++ b/ddl/50-financial-schema.sql @@ -0,0 +1,154 @@ +-- ============================================================= +-- ARCHIVO: 50-financial-schema.sql +-- DESCRIPCION: Schema de contabilidad financiera y tipos enumerados +-- VERSION: 1.0.0 +-- PROYECTO: ERP-Core V2 +-- FECHA: 2026-01-20 +-- DEPENDE DE: Ninguno (define el schema base) +-- ============================================================= + +-- ===================== +-- SCHEMA: financial +-- Schema para modulo de contabilidad general +-- ===================== +CREATE SCHEMA IF NOT EXISTS financial; + +-- ===================== +-- EXTENSIONES REQUERIDAS +-- ===================== +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- ===================== +-- TIPOS ENUMERADOS +-- ===================== + +-- Tipo de cuenta contable (activo, pasivo, capital, ingreso, gasto) +DO $$ BEGIN + CREATE TYPE financial.account_type_enum AS ENUM ( + 'asset', -- Activo + 'liability', -- Pasivo + 'equity', -- Capital/Patrimonio + 'income', -- Ingreso + 'expense' -- Gasto + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Tipo de diario contable +DO $$ BEGIN + CREATE TYPE financial.journal_type_enum AS ENUM ( + 'sale', -- Ventas + 'purchase', -- Compras + 'cash', -- Caja + 'bank', -- Banco + 'general' -- General + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Estado de asiento contable +DO $$ BEGIN + CREATE TYPE financial.entry_status_enum AS ENUM ( + 'draft', -- Borrador + 'posted', -- Publicado/Contabilizado + 'cancelled' -- Cancelado + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Estado de periodo fiscal +DO $$ BEGIN + CREATE TYPE financial.period_status_enum AS ENUM ( + 'open', -- Abierto + 'closed' -- Cerrado + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Tipo de factura +DO $$ BEGIN + CREATE TYPE financial.invoice_type_enum AS ENUM ( + 'customer', -- Factura de cliente (venta) + 'supplier' -- Factura de proveedor (compra) + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Estado de factura +DO $$ BEGIN + CREATE TYPE financial.invoice_status_enum AS ENUM ( + 'draft', -- Borrador + 'open', -- Abierta/Validada + 'paid', -- Pagada + 'cancelled' -- Cancelada + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Tipo de pago +DO $$ BEGIN + CREATE TYPE financial.payment_type_enum AS ENUM ( + 'inbound', -- Entrada (cobro) + 'outbound' -- Salida (pago) + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Metodo de pago +DO $$ BEGIN + CREATE TYPE financial.payment_method_enum AS ENUM ( + 'cash', -- Efectivo + 'bank_transfer', -- Transferencia bancaria + 'check', -- Cheque + 'card', -- Tarjeta + 'other' -- Otro + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Estado de pago +DO $$ BEGIN + CREATE TYPE financial.payment_status_enum AS ENUM ( + 'draft', -- Borrador + 'posted', -- Contabilizado + 'reconciled', -- Conciliado + 'cancelled' -- Cancelado + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Tipo de impuesto +DO $$ BEGIN + CREATE TYPE financial.tax_type_enum AS ENUM ( + 'sales', -- Solo ventas + 'purchase', -- Solo compras + 'all' -- Ambos (ventas y compras) + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- ===================== +-- COMENTARIOS DEL SCHEMA +-- ===================== +COMMENT ON SCHEMA financial IS 'Schema para el modulo de contabilidad general: plan de cuentas, diarios, asientos, facturas financieras y pagos'; + +COMMENT ON TYPE financial.account_type_enum IS 'Clasificacion de cuentas contables: activo, pasivo, capital, ingreso, gasto'; +COMMENT ON TYPE financial.journal_type_enum IS 'Tipo de diario contable: ventas, compras, caja, banco, general'; +COMMENT ON TYPE financial.entry_status_enum IS 'Estado del asiento contable: borrador, publicado, cancelado'; +COMMENT ON TYPE financial.period_status_enum IS 'Estado del periodo fiscal: abierto, cerrado'; +COMMENT ON TYPE financial.invoice_type_enum IS 'Tipo de factura financiera: cliente (venta), proveedor (compra)'; +COMMENT ON TYPE financial.invoice_status_enum IS 'Estado de factura: borrador, abierta, pagada, cancelada'; +COMMENT ON TYPE financial.payment_type_enum IS 'Direccion del pago: inbound (cobro), outbound (pago)'; +COMMENT ON TYPE financial.payment_method_enum IS 'Metodo de pago: efectivo, transferencia, cheque, tarjeta, otro'; +COMMENT ON TYPE financial.payment_status_enum IS 'Estado del pago: borrador, contabilizado, conciliado, cancelado'; +COMMENT ON TYPE financial.tax_type_enum IS 'Aplicacion del impuesto: ventas, compras, ambos'; diff --git a/ddl/51-financial-accounts.sql b/ddl/51-financial-accounts.sql new file mode 100644 index 0000000..9157d0d --- /dev/null +++ b/ddl/51-financial-accounts.sql @@ -0,0 +1,143 @@ +-- ============================================================= +-- ARCHIVO: 51-financial-accounts.sql +-- DESCRIPCION: Plan de cuentas, tipos de cuenta y mapeos +-- VERSION: 1.0.0 +-- PROYECTO: ERP-Core V2 +-- FECHA: 2026-01-20 +-- DEPENDE DE: 50-financial-schema.sql +-- ============================================================= + +-- ===================== +-- TABLA: account_types +-- Catalogo de tipos de cuenta contable +-- ===================== +CREATE TABLE IF NOT EXISTS financial.account_types ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Identificacion + code VARCHAR(20) NOT NULL UNIQUE, + name VARCHAR(100) NOT NULL, + + -- Clasificacion + account_type financial.account_type_enum NOT NULL, + + -- Descripcion + description TEXT, + + -- Metadata + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +-- Indices para account_types +CREATE INDEX IF NOT EXISTS idx_financial_account_types_code ON financial.account_types(code); +CREATE INDEX IF NOT EXISTS idx_financial_account_types_type ON financial.account_types(account_type); + +-- ===================== +-- TABLA: accounts +-- Plan de cuentas contables +-- ===================== +CREATE TABLE IF NOT EXISTS financial.accounts ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Multi-tenant + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + company_id UUID, -- FK a company si existe multi-company + + -- Identificacion + code VARCHAR(50) NOT NULL, + name VARCHAR(255) NOT NULL, + + -- Clasificacion + account_type_id UUID NOT NULL REFERENCES financial.account_types(id) ON DELETE RESTRICT, + + -- Jerarquia (cuenta padre para estructura arborea) + parent_id UUID REFERENCES financial.accounts(id) ON DELETE SET NULL, + + -- Moneda preferida + currency_id UUID, -- FK a catalogo de monedas si existe + + -- Configuracion + is_reconcilable BOOLEAN DEFAULT FALSE, -- Permite conciliacion bancaria + is_deprecated BOOLEAN DEFAULT FALSE, -- Cuenta obsoleta (no usar en nuevos movimientos) + + -- Notas + notes TEXT, + + -- Audit columns + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + created_by UUID REFERENCES auth.users(id), + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_by UUID REFERENCES auth.users(id), + deleted_at TIMESTAMPTZ, + + -- Constraint de unicidad por tenant + UNIQUE(tenant_id, code) +); + +-- Indices para accounts +CREATE INDEX IF NOT EXISTS idx_financial_accounts_tenant ON financial.accounts(tenant_id); +CREATE INDEX IF NOT EXISTS idx_financial_accounts_company ON financial.accounts(company_id); +CREATE INDEX IF NOT EXISTS idx_financial_accounts_code ON financial.accounts(code); +CREATE INDEX IF NOT EXISTS idx_financial_accounts_type ON financial.accounts(account_type_id); +CREATE INDEX IF NOT EXISTS idx_financial_accounts_parent ON financial.accounts(parent_id); +CREATE INDEX IF NOT EXISTS idx_financial_accounts_active ON financial.accounts(tenant_id) WHERE deleted_at IS NULL AND is_deprecated = FALSE; +CREATE INDEX IF NOT EXISTS idx_financial_accounts_reconcilable ON financial.accounts(tenant_id, is_reconcilable) WHERE is_reconcilable = TRUE; + +-- ===================== +-- TABLA: account_mappings +-- Mapeos de cuentas para automatizaciones +-- Ej: cuenta_ingreso_default, cuenta_iva_16, cuenta_banco_principal +-- ===================== +CREATE TABLE IF NOT EXISTS financial.account_mappings ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Multi-tenant + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + company_id UUID, + + -- Identificacion del mapeo + mapping_type VARCHAR(50) NOT NULL, -- Ej: default_income, default_expense, vat_16, bank_main + + -- Cuenta mapeada + account_id UUID NOT NULL REFERENCES financial.accounts(id) ON DELETE CASCADE, + + -- Descripcion + description TEXT, + + -- Estado + is_active BOOLEAN DEFAULT TRUE, + + -- Audit columns + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + created_by UUID REFERENCES auth.users(id), + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_by UUID REFERENCES auth.users(id), + + -- Un solo mapeo activo por tipo por tenant/company + UNIQUE(tenant_id, company_id, mapping_type) +); + +-- Indices para account_mappings +CREATE INDEX IF NOT EXISTS idx_financial_account_mappings_tenant ON financial.account_mappings(tenant_id); +CREATE INDEX IF NOT EXISTS idx_financial_account_mappings_company ON financial.account_mappings(company_id); +CREATE INDEX IF NOT EXISTS idx_financial_account_mappings_type ON financial.account_mappings(mapping_type); +CREATE INDEX IF NOT EXISTS idx_financial_account_mappings_account ON financial.account_mappings(account_id); +CREATE INDEX IF NOT EXISTS idx_financial_account_mappings_active ON financial.account_mappings(tenant_id, is_active) WHERE is_active = TRUE; + +-- ===================== +-- COMENTARIOS +-- ===================== +COMMENT ON TABLE financial.account_types IS 'Catalogo de tipos de cuenta contable (activo, pasivo, capital, ingreso, gasto)'; +COMMENT ON COLUMN financial.account_types.code IS 'Codigo unico del tipo (ej: ASSET_CURRENT, LIABILITY_LONG)'; +COMMENT ON COLUMN financial.account_types.account_type IS 'Clasificacion principal: asset, liability, equity, income, expense'; + +COMMENT ON TABLE financial.accounts IS 'Plan de cuentas contables con estructura jerarquica'; +COMMENT ON COLUMN financial.accounts.code IS 'Codigo de cuenta (ej: 1100, 1100.01)'; +COMMENT ON COLUMN financial.accounts.parent_id IS 'Referencia a cuenta padre para estructura de arbol'; +COMMENT ON COLUMN financial.accounts.is_reconcilable IS 'TRUE si la cuenta permite conciliacion bancaria'; +COMMENT ON COLUMN financial.accounts.is_deprecated IS 'TRUE si la cuenta esta obsoleta (no usar en nuevos movimientos)'; + +COMMENT ON TABLE financial.account_mappings IS 'Mapeos de cuentas para automatizaciones contables'; +COMMENT ON COLUMN financial.account_mappings.mapping_type IS 'Tipo de mapeo (ej: default_income, vat_16, bank_main)'; +COMMENT ON COLUMN financial.account_mappings.account_id IS 'Cuenta contable asociada al mapeo'; diff --git a/ddl/52-financial-journals.sql b/ddl/52-financial-journals.sql new file mode 100644 index 0000000..9d4bd49 --- /dev/null +++ b/ddl/52-financial-journals.sql @@ -0,0 +1,162 @@ +-- ============================================================= +-- ARCHIVO: 52-financial-journals.sql +-- DESCRIPCION: Diarios contables y periodos fiscales +-- VERSION: 1.0.0 +-- PROYECTO: ERP-Core V2 +-- FECHA: 2026-01-20 +-- DEPENDE DE: 50-financial-schema.sql, 51-financial-accounts.sql +-- ============================================================= + +-- ===================== +-- TABLA: fiscal_years +-- Anos fiscales +-- ===================== +CREATE TABLE IF NOT EXISTS financial.fiscal_years ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Multi-tenant + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + company_id UUID, + + -- Identificacion + name VARCHAR(100) NOT NULL, -- Ej: "Ejercicio 2026" + code VARCHAR(20) NOT NULL, -- Ej: "FY2026" + + -- Periodo + date_from DATE NOT NULL, + date_to DATE NOT NULL, + + -- Estado + status financial.period_status_enum DEFAULT 'open', + + -- Audit columns + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + created_by UUID REFERENCES auth.users(id), + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_by UUID REFERENCES auth.users(id), + + -- Validaciones + CONSTRAINT chk_fiscal_years_dates CHECK (date_to > date_from), + UNIQUE(tenant_id, code) +); + +-- Indices para fiscal_years +CREATE INDEX IF NOT EXISTS idx_financial_fiscal_years_tenant ON financial.fiscal_years(tenant_id); +CREATE INDEX IF NOT EXISTS idx_financial_fiscal_years_code ON financial.fiscal_years(code); +CREATE INDEX IF NOT EXISTS idx_financial_fiscal_years_status ON financial.fiscal_years(status); +CREATE INDEX IF NOT EXISTS idx_financial_fiscal_years_dates ON financial.fiscal_years(date_from, date_to); + +-- ===================== +-- TABLA: fiscal_periods +-- Periodos fiscales (meses o trimestres dentro de un ano fiscal) +-- ===================== +CREATE TABLE IF NOT EXISTS financial.fiscal_periods ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Multi-tenant + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + + -- Relacion con ano fiscal + fiscal_year_id UUID NOT NULL REFERENCES financial.fiscal_years(id) ON DELETE CASCADE, + + -- Identificacion + code VARCHAR(20) NOT NULL, -- Ej: "2026-01", "Q1-2026" + name VARCHAR(100) NOT NULL, -- Ej: "Enero 2026", "Primer Trimestre 2026" + + -- Periodo + date_from DATE NOT NULL, + date_to DATE NOT NULL, + + -- Estado + status financial.period_status_enum DEFAULT 'open', + + -- Cierre + closed_at TIMESTAMPTZ, + closed_by UUID REFERENCES auth.users(id), + + -- Audit columns + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + created_by UUID REFERENCES auth.users(id), + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_by UUID REFERENCES auth.users(id), + + -- Validaciones + CONSTRAINT chk_fiscal_periods_dates CHECK (date_to >= date_from), + UNIQUE(tenant_id, fiscal_year_id, code) +); + +-- Indices para fiscal_periods +CREATE INDEX IF NOT EXISTS idx_financial_fiscal_periods_tenant ON financial.fiscal_periods(tenant_id); +CREATE INDEX IF NOT EXISTS idx_financial_fiscal_periods_year ON financial.fiscal_periods(fiscal_year_id); +CREATE INDEX IF NOT EXISTS idx_financial_fiscal_periods_code ON financial.fiscal_periods(code); +CREATE INDEX IF NOT EXISTS idx_financial_fiscal_periods_status ON financial.fiscal_periods(status); +CREATE INDEX IF NOT EXISTS idx_financial_fiscal_periods_dates ON financial.fiscal_periods(date_from, date_to); +CREATE INDEX IF NOT EXISTS idx_financial_fiscal_periods_open ON financial.fiscal_periods(tenant_id, status) WHERE status = 'open'; + +-- ===================== +-- TABLA: journals +-- Diarios contables (ventas, compras, caja, banco, general) +-- ===================== +CREATE TABLE IF NOT EXISTS financial.journals ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Multi-tenant + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + company_id UUID, + + -- Identificacion + name VARCHAR(255) NOT NULL, + code VARCHAR(20) NOT NULL, + + -- Tipo de diario + journal_type financial.journal_type_enum NOT NULL, + + -- Cuenta por defecto (para asientos automaticos) + default_account_id UUID REFERENCES financial.accounts(id) ON DELETE SET NULL, + + -- Secuencia para numeracion + sequence_id UUID, -- FK a sistema de secuencias si existe + + -- Moneda preferida + currency_id UUID, + + -- Estado + active BOOLEAN DEFAULT TRUE, + + -- Audit columns con soft delete + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + created_by UUID REFERENCES auth.users(id), + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_by UUID REFERENCES auth.users(id), + deleted_at TIMESTAMPTZ, + + -- Unicidad por tenant + UNIQUE(tenant_id, code) +); + +-- Indices para journals +CREATE INDEX IF NOT EXISTS idx_financial_journals_tenant ON financial.journals(tenant_id); +CREATE INDEX IF NOT EXISTS idx_financial_journals_company ON financial.journals(company_id); +CREATE INDEX IF NOT EXISTS idx_financial_journals_code ON financial.journals(code); +CREATE INDEX IF NOT EXISTS idx_financial_journals_type ON financial.journals(journal_type); +CREATE INDEX IF NOT EXISTS idx_financial_journals_default_account ON financial.journals(default_account_id); +CREATE INDEX IF NOT EXISTS idx_financial_journals_active ON financial.journals(tenant_id) WHERE active = TRUE AND deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_financial_journals_by_type_active ON financial.journals(tenant_id, journal_type) WHERE active = TRUE AND deleted_at IS NULL; + +-- ===================== +-- COMENTARIOS +-- ===================== +COMMENT ON TABLE financial.fiscal_years IS 'Anos fiscales para organizacion contable'; +COMMENT ON COLUMN financial.fiscal_years.code IS 'Codigo unico del ano fiscal (ej: FY2026)'; +COMMENT ON COLUMN financial.fiscal_years.status IS 'Estado: open (permite movimientos), closed (no permite movimientos)'; + +COMMENT ON TABLE financial.fiscal_periods IS 'Periodos fiscales (meses o trimestres) dentro de un ano fiscal'; +COMMENT ON COLUMN financial.fiscal_periods.code IS 'Codigo del periodo (ej: 2026-01, Q1-2026)'; +COMMENT ON COLUMN financial.fiscal_periods.closed_at IS 'Fecha y hora de cierre del periodo'; +COMMENT ON COLUMN financial.fiscal_periods.closed_by IS 'Usuario que cerro el periodo'; + +COMMENT ON TABLE financial.journals IS 'Diarios contables para agrupar asientos por tipo de operacion'; +COMMENT ON COLUMN financial.journals.code IS 'Codigo unico del diario (ej: VTAS, COMP, CAJA, BCO)'; +COMMENT ON COLUMN financial.journals.journal_type IS 'Tipo: sale (ventas), purchase (compras), cash (caja), bank (banco), general'; +COMMENT ON COLUMN financial.journals.default_account_id IS 'Cuenta por defecto para asientos automaticos'; +COMMENT ON COLUMN financial.journals.sequence_id IS 'Referencia a secuencia para numeracion automatica'; diff --git a/ddl/53-financial-entries.sql b/ddl/53-financial-entries.sql new file mode 100644 index 0000000..90c9d20 --- /dev/null +++ b/ddl/53-financial-entries.sql @@ -0,0 +1,175 @@ +-- ============================================================= +-- ARCHIVO: 53-financial-entries.sql +-- DESCRIPCION: Asientos contables y lineas de asiento +-- VERSION: 1.0.0 +-- PROYECTO: ERP-Core V2 +-- FECHA: 2026-01-20 +-- DEPENDE DE: 50-financial-schema.sql, 51-financial-accounts.sql, 52-financial-journals.sql +-- ============================================================= + +-- ===================== +-- TABLA: journal_entries +-- Asientos contables (cabecera) +-- ===================== +CREATE TABLE IF NOT EXISTS financial.journal_entries ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Multi-tenant + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + company_id UUID, + + -- Diario + journal_id UUID NOT NULL REFERENCES financial.journals(id) ON DELETE RESTRICT, + + -- Identificacion + name VARCHAR(100) NOT NULL, -- Numero o identificador del asiento + ref VARCHAR(255), -- Referencia externa (factura, pago, etc.) + + -- Fecha + date DATE NOT NULL, + + -- Estado + status financial.entry_status_enum DEFAULT 'draft', + + -- Notas + notes TEXT, + + -- Periodo fiscal + fiscal_period_id UUID REFERENCES financial.fiscal_periods(id) ON DELETE RESTRICT, + + -- Audit columns + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + created_by UUID REFERENCES auth.users(id), + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_by UUID REFERENCES auth.users(id), + + -- Publicacion + posted_at TIMESTAMPTZ, + posted_by UUID REFERENCES auth.users(id), + + -- Cancelacion + cancelled_at TIMESTAMPTZ, + cancelled_by UUID REFERENCES auth.users(id) +); + +-- Indices para journal_entries +CREATE INDEX IF NOT EXISTS idx_financial_journal_entries_tenant ON financial.journal_entries(tenant_id); +CREATE INDEX IF NOT EXISTS idx_financial_journal_entries_company ON financial.journal_entries(company_id); +CREATE INDEX IF NOT EXISTS idx_financial_journal_entries_journal ON financial.journal_entries(journal_id); +CREATE INDEX IF NOT EXISTS idx_financial_journal_entries_name ON financial.journal_entries(name); +CREATE INDEX IF NOT EXISTS idx_financial_journal_entries_ref ON financial.journal_entries(ref); +CREATE INDEX IF NOT EXISTS idx_financial_journal_entries_date ON financial.journal_entries(date); +CREATE INDEX IF NOT EXISTS idx_financial_journal_entries_status ON financial.journal_entries(status); +CREATE INDEX IF NOT EXISTS idx_financial_journal_entries_period ON financial.journal_entries(fiscal_period_id); +CREATE INDEX IF NOT EXISTS idx_financial_journal_entries_posted ON financial.journal_entries(tenant_id, status) WHERE status = 'posted'; +CREATE INDEX IF NOT EXISTS idx_financial_journal_entries_draft ON financial.journal_entries(tenant_id, status) WHERE status = 'draft'; +CREATE INDEX IF NOT EXISTS idx_financial_journal_entries_date_range ON financial.journal_entries(tenant_id, date, status); + +-- ===================== +-- TABLA: journal_entry_lines +-- Lineas de asiento contable (debe/haber) +-- ===================== +CREATE TABLE IF NOT EXISTS financial.journal_entry_lines ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Relacion con asiento (cascade delete) + entry_id UUID NOT NULL REFERENCES financial.journal_entries(id) ON DELETE CASCADE, + + -- Multi-tenant (denormalizado para queries rapidas) + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + + -- Cuenta contable + account_id UUID NOT NULL REFERENCES financial.accounts(id) ON DELETE RESTRICT, + + -- Partner asociado (opcional, para cuentas por cobrar/pagar) + partner_id UUID, -- FK a partners si existe + + -- Montos (solo debe o solo haber, nunca ambos) + debit DECIMAL(15, 2) DEFAULT 0 CHECK (debit >= 0), + credit DECIMAL(15, 2) DEFAULT 0 CHECK (credit >= 0), + + -- Descripcion de la linea + description TEXT, + + -- Referencia adicional + ref VARCHAR(255), + + -- Metadata + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + + -- Validacion: debe tener debit XOR credit (no ambos, no ninguno) + CONSTRAINT chk_journal_entry_lines_debit_credit CHECK ( + (debit > 0 AND credit = 0) OR (debit = 0 AND credit > 0) + ) +); + +-- Indices para journal_entry_lines +CREATE INDEX IF NOT EXISTS idx_financial_journal_entry_lines_entry ON financial.journal_entry_lines(entry_id); +CREATE INDEX IF NOT EXISTS idx_financial_journal_entry_lines_tenant ON financial.journal_entry_lines(tenant_id); +CREATE INDEX IF NOT EXISTS idx_financial_journal_entry_lines_account ON financial.journal_entry_lines(account_id); +CREATE INDEX IF NOT EXISTS idx_financial_journal_entry_lines_partner ON financial.journal_entry_lines(partner_id) WHERE partner_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_financial_journal_entry_lines_debit ON financial.journal_entry_lines(account_id, debit) WHERE debit > 0; +CREATE INDEX IF NOT EXISTS idx_financial_journal_entry_lines_credit ON financial.journal_entry_lines(account_id, credit) WHERE credit > 0; + +-- ===================== +-- FUNCION: Validar balance de asiento +-- Un asiento debe estar balanceado (sum debit = sum credit) +-- ===================== +CREATE OR REPLACE FUNCTION financial.check_entry_balance() +RETURNS TRIGGER AS $$ +DECLARE + v_total_debit DECIMAL(15, 2); + v_total_credit DECIMAL(15, 2); + v_entry_status financial.entry_status_enum; +BEGIN + -- Solo validar cuando el asiento se publica + SELECT status INTO v_entry_status + FROM financial.journal_entries + WHERE id = COALESCE(NEW.entry_id, OLD.entry_id); + + -- Solo validar si el asiento esta siendo publicado + IF v_entry_status = 'posted' THEN + SELECT + COALESCE(SUM(debit), 0), + COALESCE(SUM(credit), 0) + INTO v_total_debit, v_total_credit + FROM financial.journal_entry_lines + WHERE entry_id = COALESCE(NEW.entry_id, OLD.entry_id); + + IF v_total_debit != v_total_credit THEN + RAISE EXCEPTION 'El asiento no esta balanceado. Debe: %, Haber: %', + v_total_debit, v_total_credit; + END IF; + END IF; + + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql; + +-- Trigger para validar balance (se ejecuta despues de INSERT/UPDATE/DELETE en lineas) +-- Nota: El trigger se crea pero puede deshabilitarse en ambientes de migracion +DROP TRIGGER IF EXISTS trg_check_entry_balance ON financial.journal_entry_lines; +-- CREATE TRIGGER trg_check_entry_balance +-- AFTER INSERT OR UPDATE OR DELETE ON financial.journal_entry_lines +-- FOR EACH ROW +-- EXECUTE FUNCTION financial.check_entry_balance(); + +-- ===================== +-- COMENTARIOS +-- ===================== +COMMENT ON TABLE financial.journal_entries IS 'Cabecera de asientos contables'; +COMMENT ON COLUMN financial.journal_entries.name IS 'Numero o identificador unico del asiento'; +COMMENT ON COLUMN financial.journal_entries.ref IS 'Referencia externa (numero de factura, pago, etc.)'; +COMMENT ON COLUMN financial.journal_entries.status IS 'Estado: draft (editable), posted (contabilizado), cancelled'; +COMMENT ON COLUMN financial.journal_entries.fiscal_period_id IS 'Periodo fiscal al que pertenece el asiento'; +COMMENT ON COLUMN financial.journal_entries.posted_at IS 'Fecha y hora de publicacion/contabilizacion'; +COMMENT ON COLUMN financial.journal_entries.cancelled_at IS 'Fecha y hora de cancelacion'; + +COMMENT ON TABLE financial.journal_entry_lines IS 'Lineas de asiento contable (partidas de debe y haber)'; +COMMENT ON COLUMN financial.journal_entry_lines.account_id IS 'Cuenta contable afectada'; +COMMENT ON COLUMN financial.journal_entry_lines.partner_id IS 'Partner asociado (para cuentas por cobrar/pagar)'; +COMMENT ON COLUMN financial.journal_entry_lines.debit IS 'Monto al debe (cargo)'; +COMMENT ON COLUMN financial.journal_entry_lines.credit IS 'Monto al haber (abono)'; +COMMENT ON COLUMN financial.journal_entry_lines.description IS 'Descripcion o concepto de la linea'; + +COMMENT ON FUNCTION financial.check_entry_balance() IS 'Valida que el asiento este balanceado (sum debit = sum credit)'; diff --git a/ddl/54-financial-invoices.sql b/ddl/54-financial-invoices.sql new file mode 100644 index 0000000..5ad16da --- /dev/null +++ b/ddl/54-financial-invoices.sql @@ -0,0 +1,167 @@ +-- ============================================================= +-- ARCHIVO: 54-financial-invoices.sql +-- DESCRIPCION: Facturas contables (cliente/proveedor) y lineas de factura +-- VERSION: 1.0.0 +-- PROYECTO: ERP-Core V2 +-- FECHA: 2026-01-20 +-- DEPENDE DE: 50-financial-schema.sql, 51-financial-accounts.sql, 52-financial-journals.sql, 53-financial-entries.sql +-- NOTA: Este modulo es para facturas desde perspectiva CONTABLE. +-- Para facturacion operativa ver 24-invoices.sql (schema billing) +-- ============================================================= + +-- ===================== +-- TABLA: invoices +-- Facturas contables (cliente y proveedor) +-- ===================== +CREATE TABLE IF NOT EXISTS financial.invoices ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Multi-tenant + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + company_id UUID, + + -- Partner (cliente o proveedor) + partner_id UUID NOT NULL, -- FK a partners.partners + + -- Tipo de factura + invoice_type financial.invoice_type_enum NOT NULL, + + -- Identificacion + number VARCHAR(100) NOT NULL, -- Numero de factura + ref VARCHAR(255), -- Referencia externa + + -- Fechas + invoice_date DATE NOT NULL, + due_date DATE, + + -- Moneda + currency_id UUID, -- FK a catalogo de monedas + + -- Montos + amount_untaxed DECIMAL(15, 2) DEFAULT 0, -- Subtotal sin impuestos + amount_tax DECIMAL(15, 2) DEFAULT 0, -- Total impuestos + amount_total DECIMAL(15, 2) DEFAULT 0, -- Total de la factura + amount_paid DECIMAL(15, 2) DEFAULT 0, -- Monto pagado + amount_residual DECIMAL(15, 2) GENERATED ALWAYS AS (amount_total - COALESCE(amount_paid, 0)) STORED, -- Saldo pendiente + + -- Estado + status financial.invoice_status_enum DEFAULT 'draft', + + -- Terminos de pago + payment_term_id UUID, -- FK a terminos de pago si existe + + -- Relacion con contabilidad + journal_id UUID REFERENCES financial.journals(id) ON DELETE RESTRICT, + journal_entry_id UUID REFERENCES financial.journal_entries(id) ON DELETE SET NULL, + + -- Notas + notes TEXT, + + -- Audit columns + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + created_by UUID REFERENCES auth.users(id), + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_by UUID REFERENCES auth.users(id), + + -- Validacion + validated_at TIMESTAMPTZ, + validated_by UUID REFERENCES auth.users(id), + + -- Cancelacion + cancelled_at TIMESTAMPTZ, + cancelled_by UUID REFERENCES auth.users(id), + + -- Unicidad + UNIQUE(tenant_id, number, invoice_type) +); + +-- Indices para invoices +CREATE INDEX IF NOT EXISTS idx_financial_invoices_tenant ON financial.invoices(tenant_id); +CREATE INDEX IF NOT EXISTS idx_financial_invoices_company ON financial.invoices(company_id); +CREATE INDEX IF NOT EXISTS idx_financial_invoices_partner ON financial.invoices(partner_id); +CREATE INDEX IF NOT EXISTS idx_financial_invoices_type ON financial.invoices(invoice_type); +CREATE INDEX IF NOT EXISTS idx_financial_invoices_number ON financial.invoices(number); +CREATE INDEX IF NOT EXISTS idx_financial_invoices_date ON financial.invoices(invoice_date); +CREATE INDEX IF NOT EXISTS idx_financial_invoices_due_date ON financial.invoices(due_date); +CREATE INDEX IF NOT EXISTS idx_financial_invoices_status ON financial.invoices(status); +CREATE INDEX IF NOT EXISTS idx_financial_invoices_journal ON financial.invoices(journal_id); +CREATE INDEX IF NOT EXISTS idx_financial_invoices_entry ON financial.invoices(journal_entry_id); +CREATE INDEX IF NOT EXISTS idx_financial_invoices_open ON financial.invoices(tenant_id, status) WHERE status = 'open'; +CREATE INDEX IF NOT EXISTS idx_financial_invoices_unpaid ON financial.invoices(tenant_id, due_date) WHERE status = 'open' AND amount_paid < amount_total; +CREATE INDEX IF NOT EXISTS idx_financial_invoices_customer ON financial.invoices(tenant_id, partner_id, invoice_type) WHERE invoice_type = 'customer'; +CREATE INDEX IF NOT EXISTS idx_financial_invoices_supplier ON financial.invoices(tenant_id, partner_id, invoice_type) WHERE invoice_type = 'supplier'; + +-- ===================== +-- TABLA: invoice_lines +-- Lineas de factura contable +-- ===================== +CREATE TABLE IF NOT EXISTS financial.invoice_lines ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Relacion con factura (cascade delete) + invoice_id UUID NOT NULL REFERENCES financial.invoices(id) ON DELETE CASCADE, + + -- Multi-tenant (denormalizado para queries rapidas) + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + + -- Producto (opcional) + product_id UUID, -- FK a products.products + + -- Descripcion + description TEXT, + + -- Cantidad y unidad + quantity DECIMAL(15, 4) NOT NULL DEFAULT 1, + uom_id UUID, -- FK a unidades de medida + + -- Precio + price_unit DECIMAL(15, 2) NOT NULL DEFAULT 0, + + -- Impuestos aplicables (array de UUIDs de taxes) + tax_ids UUID[] DEFAULT '{}', + + -- Montos calculados + amount_untaxed DECIMAL(15, 2) DEFAULT 0, -- subtotal linea + amount_tax DECIMAL(15, 2) DEFAULT 0, -- impuestos linea + amount_total DECIMAL(15, 2) DEFAULT 0, -- total linea + + -- Cuenta contable + account_id UUID REFERENCES financial.accounts(id) ON DELETE RESTRICT, + + -- Audit columns + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + created_by UUID REFERENCES auth.users(id), + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_by UUID REFERENCES auth.users(id) +); + +-- Indices para invoice_lines +CREATE INDEX IF NOT EXISTS idx_financial_invoice_lines_invoice ON financial.invoice_lines(invoice_id); +CREATE INDEX IF NOT EXISTS idx_financial_invoice_lines_tenant ON financial.invoice_lines(tenant_id); +CREATE INDEX IF NOT EXISTS idx_financial_invoice_lines_product ON financial.invoice_lines(product_id) WHERE product_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_financial_invoice_lines_account ON financial.invoice_lines(account_id); +CREATE INDEX IF NOT EXISTS idx_financial_invoice_lines_tax_ids ON financial.invoice_lines USING GIN(tax_ids); + +-- ===================== +-- COMENTARIOS +-- ===================== +COMMENT ON TABLE financial.invoices IS 'Facturas contables (perspectiva financiera)'; +COMMENT ON COLUMN financial.invoices.invoice_type IS 'Tipo: customer (venta a cliente), supplier (compra a proveedor)'; +COMMENT ON COLUMN financial.invoices.number IS 'Numero unico de factura'; +COMMENT ON COLUMN financial.invoices.ref IS 'Referencia externa (numero de factura del proveedor, etc.)'; +COMMENT ON COLUMN financial.invoices.amount_untaxed IS 'Subtotal sin impuestos'; +COMMENT ON COLUMN financial.invoices.amount_tax IS 'Total de impuestos'; +COMMENT ON COLUMN financial.invoices.amount_total IS 'Total de la factura (subtotal + impuestos)'; +COMMENT ON COLUMN financial.invoices.amount_paid IS 'Monto pagado hasta el momento'; +COMMENT ON COLUMN financial.invoices.amount_residual IS 'Saldo pendiente de pago (calculado)'; +COMMENT ON COLUMN financial.invoices.status IS 'Estado: draft, open (validada), paid, cancelled'; +COMMENT ON COLUMN financial.invoices.journal_entry_id IS 'Asiento contable generado al validar la factura'; +COMMENT ON COLUMN financial.invoices.validated_at IS 'Fecha y hora de validacion/apertura'; +COMMENT ON COLUMN financial.invoices.cancelled_at IS 'Fecha y hora de cancelacion'; + +COMMENT ON TABLE financial.invoice_lines IS 'Lineas de detalle de facturas contables'; +COMMENT ON COLUMN financial.invoice_lines.product_id IS 'Producto asociado (opcional)'; +COMMENT ON COLUMN financial.invoice_lines.quantity IS 'Cantidad facturada'; +COMMENT ON COLUMN financial.invoice_lines.price_unit IS 'Precio unitario'; +COMMENT ON COLUMN financial.invoice_lines.tax_ids IS 'Array de IDs de impuestos aplicables'; +COMMENT ON COLUMN financial.invoice_lines.account_id IS 'Cuenta contable para el asiento'; diff --git a/ddl/55-financial-payments.sql b/ddl/55-financial-payments.sql new file mode 100644 index 0000000..da77193 --- /dev/null +++ b/ddl/55-financial-payments.sql @@ -0,0 +1,174 @@ +-- ============================================================= +-- ARCHIVO: 55-financial-payments.sql +-- DESCRIPCION: Pagos contables (cobros y pagos) +-- VERSION: 1.0.0 +-- PROYECTO: ERP-Core V2 +-- FECHA: 2026-01-20 +-- DEPENDE DE: 50-financial-schema.sql, 51-financial-accounts.sql, 52-financial-journals.sql, 53-financial-entries.sql +-- NOTA: Este modulo es para pagos desde perspectiva CONTABLE. +-- Para pagos operativos ver 24-invoices.sql (schema billing) +-- ============================================================= + +-- ===================== +-- TABLA: payments +-- Pagos contables (cobros entrantes y pagos salientes) +-- ===================== +CREATE TABLE IF NOT EXISTS financial.payments ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Multi-tenant + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + company_id UUID, + + -- Partner (cliente o proveedor) + partner_id UUID NOT NULL, -- FK a partners.partners + + -- Tipo de pago + payment_type financial.payment_type_enum NOT NULL, -- inbound (cobro), outbound (pago) + + -- Metodo de pago + payment_method financial.payment_method_enum NOT NULL, + + -- Monto + amount DECIMAL(15, 2) NOT NULL CHECK (amount > 0), + + -- Moneda + currency_id UUID, -- FK a catalogo de monedas + + -- Fecha de pago + payment_date DATE NOT NULL, + + -- Referencia + ref VARCHAR(255), -- Numero de cheque, referencia bancaria, etc. + + -- Estado + status financial.payment_status_enum DEFAULT 'draft', + + -- Relacion con contabilidad + journal_id UUID REFERENCES financial.journals(id) ON DELETE RESTRICT, + journal_entry_id UUID REFERENCES financial.journal_entries(id) ON DELETE SET NULL, + + -- Notas + notes TEXT, + + -- Audit columns + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + created_by UUID REFERENCES auth.users(id), + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_by UUID REFERENCES auth.users(id), + + -- Publicacion/Contabilizacion + posted_at TIMESTAMPTZ, + posted_by UUID REFERENCES auth.users(id) +); + +-- Indices para payments +CREATE INDEX IF NOT EXISTS idx_financial_payments_tenant ON financial.payments(tenant_id); +CREATE INDEX IF NOT EXISTS idx_financial_payments_company ON financial.payments(company_id); +CREATE INDEX IF NOT EXISTS idx_financial_payments_partner ON financial.payments(partner_id); +CREATE INDEX IF NOT EXISTS idx_financial_payments_type ON financial.payments(payment_type); +CREATE INDEX IF NOT EXISTS idx_financial_payments_method ON financial.payments(payment_method); +CREATE INDEX IF NOT EXISTS idx_financial_payments_date ON financial.payments(payment_date); +CREATE INDEX IF NOT EXISTS idx_financial_payments_status ON financial.payments(status); +CREATE INDEX IF NOT EXISTS idx_financial_payments_journal ON financial.payments(journal_id); +CREATE INDEX IF NOT EXISTS idx_financial_payments_entry ON financial.payments(journal_entry_id); +CREATE INDEX IF NOT EXISTS idx_financial_payments_ref ON financial.payments(ref) WHERE ref IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_financial_payments_posted ON financial.payments(tenant_id, status) WHERE status = 'posted'; +CREATE INDEX IF NOT EXISTS idx_financial_payments_inbound ON financial.payments(tenant_id, partner_id, payment_type) WHERE payment_type = 'inbound'; +CREATE INDEX IF NOT EXISTS idx_financial_payments_outbound ON financial.payments(tenant_id, partner_id, payment_type) WHERE payment_type = 'outbound'; +CREATE INDEX IF NOT EXISTS idx_financial_payments_date_range ON financial.payments(tenant_id, payment_date, status); + +-- ===================== +-- TABLA: payment_invoice_allocations +-- Aplicacion de pagos a facturas +-- ===================== +CREATE TABLE IF NOT EXISTS financial.payment_invoice_allocations ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Pago + payment_id UUID NOT NULL REFERENCES financial.payments(id) ON DELETE CASCADE, + + -- Factura + invoice_id UUID NOT NULL REFERENCES financial.invoices(id) ON DELETE CASCADE, + + -- Monto aplicado a esta factura + amount DECIMAL(15, 2) NOT NULL CHECK (amount > 0), + + -- Fecha de aplicacion + allocation_date DATE NOT NULL DEFAULT CURRENT_DATE, + + -- Audit columns + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + created_by UUID REFERENCES auth.users(id), + + -- Un pago solo puede aplicarse una vez a cada factura + UNIQUE(payment_id, invoice_id) +); + +-- Indices para payment_invoice_allocations +CREATE INDEX IF NOT EXISTS idx_financial_payment_allocations_payment ON financial.payment_invoice_allocations(payment_id); +CREATE INDEX IF NOT EXISTS idx_financial_payment_allocations_invoice ON financial.payment_invoice_allocations(invoice_id); +CREATE INDEX IF NOT EXISTS idx_financial_payment_allocations_date ON financial.payment_invoice_allocations(allocation_date); + +-- ===================== +-- FUNCION: Actualizar amount_paid en factura +-- ===================== +CREATE OR REPLACE FUNCTION financial.update_invoice_amount_paid() +RETURNS TRIGGER AS $$ +DECLARE + v_invoice_id UUID; + v_total_paid DECIMAL(15, 2); +BEGIN + -- Determinar la factura afectada + IF TG_OP = 'DELETE' THEN + v_invoice_id := OLD.invoice_id; + ELSE + v_invoice_id := NEW.invoice_id; + END IF; + + -- Calcular total pagado para la factura + SELECT COALESCE(SUM(amount), 0) + INTO v_total_paid + FROM financial.payment_invoice_allocations + WHERE invoice_id = v_invoice_id; + + -- Actualizar factura + UPDATE financial.invoices + SET + amount_paid = v_total_paid, + status = CASE + WHEN v_total_paid >= amount_total THEN 'paid'::financial.invoice_status_enum + WHEN v_total_paid > 0 THEN 'open'::financial.invoice_status_enum + ELSE status + END, + updated_at = CURRENT_TIMESTAMP + WHERE id = v_invoice_id; + + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql; + +-- Trigger para actualizar amount_paid automaticamente +DROP TRIGGER IF EXISTS trg_update_invoice_amount_paid ON financial.payment_invoice_allocations; +CREATE TRIGGER trg_update_invoice_amount_paid + AFTER INSERT OR UPDATE OR DELETE ON financial.payment_invoice_allocations + FOR EACH ROW + EXECUTE FUNCTION financial.update_invoice_amount_paid(); + +-- ===================== +-- COMENTARIOS +-- ===================== +COMMENT ON TABLE financial.payments IS 'Pagos contables (cobros y pagos a proveedores)'; +COMMENT ON COLUMN financial.payments.payment_type IS 'Tipo: inbound (cobro de cliente), outbound (pago a proveedor)'; +COMMENT ON COLUMN financial.payments.payment_method IS 'Metodo: cash, bank_transfer, check, card, other'; +COMMENT ON COLUMN financial.payments.amount IS 'Monto del pago (siempre positivo)'; +COMMENT ON COLUMN financial.payments.ref IS 'Referencia: numero de cheque, referencia bancaria, etc.'; +COMMENT ON COLUMN financial.payments.status IS 'Estado: draft, posted (contabilizado), reconciled, cancelled'; +COMMENT ON COLUMN financial.payments.journal_entry_id IS 'Asiento contable generado al publicar el pago'; +COMMENT ON COLUMN financial.payments.posted_at IS 'Fecha y hora de publicacion/contabilizacion'; + +COMMENT ON TABLE financial.payment_invoice_allocations IS 'Aplicacion de pagos a facturas especificas'; +COMMENT ON COLUMN financial.payment_invoice_allocations.amount IS 'Monto del pago aplicado a esta factura'; +COMMENT ON COLUMN financial.payment_invoice_allocations.allocation_date IS 'Fecha de aplicacion del pago'; + +COMMENT ON FUNCTION financial.update_invoice_amount_paid() IS 'Actualiza automaticamente amount_paid en facturas cuando se aplican pagos'; diff --git a/ddl/56-financial-taxes.sql b/ddl/56-financial-taxes.sql new file mode 100644 index 0000000..58e921a --- /dev/null +++ b/ddl/56-financial-taxes.sql @@ -0,0 +1,155 @@ +-- ============================================================= +-- ARCHIVO: 56-financial-taxes.sql +-- DESCRIPCION: Impuestos contables (IVA, retenciones, etc.) +-- VERSION: 1.0.0 +-- PROYECTO: ERP-Core V2 +-- FECHA: 2026-01-20 +-- DEPENDE DE: 50-financial-schema.sql, 51-financial-accounts.sql +-- ============================================================= + +-- ===================== +-- TABLA: taxes +-- Catalogo de impuestos +-- ===================== +CREATE TABLE IF NOT EXISTS financial.taxes ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Multi-tenant + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + company_id UUID, + + -- Identificacion + name VARCHAR(100) NOT NULL, -- Ej: "IVA 16%", "Retencion ISR 10%" + code VARCHAR(20) NOT NULL, -- Ej: "IVA16", "RET_ISR10" + + -- Tipo de impuesto + tax_type financial.tax_type_enum NOT NULL DEFAULT 'all', + + -- Tasa + amount DECIMAL(5, 2) NOT NULL, -- Porcentaje (ej: 16.00 para 16%) + + -- Configuracion + included_in_price BOOLEAN DEFAULT FALSE, -- TRUE si el precio ya incluye el impuesto + + -- Estado + active BOOLEAN DEFAULT TRUE, + + -- Cuentas contables asociadas (opcional) + account_id UUID REFERENCES financial.accounts(id) ON DELETE SET NULL, -- Cuenta de impuesto + refund_account_id UUID REFERENCES financial.accounts(id) ON DELETE SET NULL, -- Cuenta para devoluciones + + -- Audit columns + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + created_by UUID REFERENCES auth.users(id), + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_by UUID REFERENCES auth.users(id), + + -- Unicidad por tenant + UNIQUE(tenant_id, code) +); + +-- Indices para taxes +CREATE INDEX IF NOT EXISTS idx_financial_taxes_tenant ON financial.taxes(tenant_id); +CREATE INDEX IF NOT EXISTS idx_financial_taxes_company ON financial.taxes(company_id); +CREATE INDEX IF NOT EXISTS idx_financial_taxes_code ON financial.taxes(code); +CREATE INDEX IF NOT EXISTS idx_financial_taxes_type ON financial.taxes(tax_type); +CREATE INDEX IF NOT EXISTS idx_financial_taxes_active ON financial.taxes(tenant_id) WHERE active = TRUE; +CREATE INDEX IF NOT EXISTS idx_financial_taxes_sales ON financial.taxes(tenant_id, tax_type) WHERE tax_type IN ('sales', 'all') AND active = TRUE; +CREATE INDEX IF NOT EXISTS idx_financial_taxes_purchase ON financial.taxes(tenant_id, tax_type) WHERE tax_type IN ('purchase', 'all') AND active = TRUE; +CREATE INDEX IF NOT EXISTS idx_financial_taxes_account ON financial.taxes(account_id) WHERE account_id IS NOT NULL; + +-- ===================== +-- TABLA: tax_groups (opcional, para agrupar impuestos) +-- Grupos de impuestos para aplicacion conjunta +-- ===================== +CREATE TABLE IF NOT EXISTS financial.tax_groups ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Multi-tenant + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + + -- Identificacion + name VARCHAR(100) NOT NULL, + code VARCHAR(20) NOT NULL, + + -- Descripcion + description TEXT, + + -- Impuestos en el grupo (array de IDs) + tax_ids UUID[] DEFAULT '{}', + + -- Estado + active BOOLEAN DEFAULT TRUE, + + -- Audit columns + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + created_by UUID REFERENCES auth.users(id), + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_by UUID REFERENCES auth.users(id), + + UNIQUE(tenant_id, code) +); + +-- Indices para tax_groups +CREATE INDEX IF NOT EXISTS idx_financial_tax_groups_tenant ON financial.tax_groups(tenant_id); +CREATE INDEX IF NOT EXISTS idx_financial_tax_groups_code ON financial.tax_groups(code); +CREATE INDEX IF NOT EXISTS idx_financial_tax_groups_active ON financial.tax_groups(tenant_id) WHERE active = TRUE; +CREATE INDEX IF NOT EXISTS idx_financial_tax_groups_tax_ids ON financial.tax_groups USING GIN(tax_ids); + +-- ===================== +-- DATOS SEMILLA: Impuestos comunes de Mexico +-- ===================== +-- Nota: Estos se insertan condicionalmente. En produccion, los impuestos +-- se crean por tenant desde la aplicacion. + +-- Funcion para insertar impuestos semilla +CREATE OR REPLACE FUNCTION financial.seed_default_taxes(p_tenant_id UUID) +RETURNS void AS $$ +BEGIN + -- IVA 16% (tasa general) + INSERT INTO financial.taxes (tenant_id, code, name, tax_type, amount, included_in_price, active) + VALUES (p_tenant_id, 'IVA16', 'IVA 16%', 'all', 16.00, FALSE, TRUE) + ON CONFLICT (tenant_id, code) DO NOTHING; + + -- IVA 8% (frontera) + INSERT INTO financial.taxes (tenant_id, code, name, tax_type, amount, included_in_price, active) + VALUES (p_tenant_id, 'IVA8', 'IVA 8% (Frontera)', 'all', 8.00, FALSE, TRUE) + ON CONFLICT (tenant_id, code) DO NOTHING; + + -- IVA 0% (tasa cero) + INSERT INTO financial.taxes (tenant_id, code, name, tax_type, amount, included_in_price, active) + VALUES (p_tenant_id, 'IVA0', 'IVA 0%', 'all', 0.00, FALSE, TRUE) + ON CONFLICT (tenant_id, code) DO NOTHING; + + -- IVA Exento + INSERT INTO financial.taxes (tenant_id, code, name, tax_type, amount, included_in_price, active) + VALUES (p_tenant_id, 'EXENTO', 'Exento de IVA', 'all', 0.00, FALSE, TRUE) + ON CONFLICT (tenant_id, code) DO NOTHING; + + -- Retencion ISR 10% + INSERT INTO financial.taxes (tenant_id, code, name, tax_type, amount, included_in_price, active) + VALUES (p_tenant_id, 'RET_ISR10', 'Retencion ISR 10%', 'purchase', -10.00, FALSE, TRUE) + ON CONFLICT (tenant_id, code) DO NOTHING; + + -- Retencion IVA 10.67% + INSERT INTO financial.taxes (tenant_id, code, name, tax_type, amount, included_in_price, active) + VALUES (p_tenant_id, 'RET_IVA', 'Retencion IVA 2/3', 'purchase', -10.67, FALSE, TRUE) + ON CONFLICT (tenant_id, code) DO NOTHING; +END; +$$ LANGUAGE plpgsql; + +-- ===================== +-- COMENTARIOS +-- ===================== +COMMENT ON TABLE financial.taxes IS 'Catalogo de impuestos (IVA, retenciones, etc.)'; +COMMENT ON COLUMN financial.taxes.code IS 'Codigo unico del impuesto (ej: IVA16, RET_ISR10)'; +COMMENT ON COLUMN financial.taxes.tax_type IS 'Aplicacion: sales (solo ventas), purchase (solo compras), all (ambos)'; +COMMENT ON COLUMN financial.taxes.amount IS 'Tasa del impuesto en porcentaje (ej: 16.00 para 16%). Negativo para retenciones.'; +COMMENT ON COLUMN financial.taxes.included_in_price IS 'TRUE si el precio del producto ya incluye este impuesto'; +COMMENT ON COLUMN financial.taxes.account_id IS 'Cuenta contable donde se registra el impuesto'; +COMMENT ON COLUMN financial.taxes.refund_account_id IS 'Cuenta contable para devoluciones/notas de credito'; + +COMMENT ON TABLE financial.tax_groups IS 'Grupos de impuestos para aplicacion conjunta (ej: IVA + Retenciones)'; +COMMENT ON COLUMN financial.tax_groups.tax_ids IS 'Array de IDs de impuestos que componen el grupo'; + +COMMENT ON FUNCTION financial.seed_default_taxes(UUID) IS 'Inserta impuestos predeterminados de Mexico para un tenant'; diff --git a/ddl/57-financial-bank-reconciliation.sql b/ddl/57-financial-bank-reconciliation.sql new file mode 100644 index 0000000..44bee6a --- /dev/null +++ b/ddl/57-financial-bank-reconciliation.sql @@ -0,0 +1,254 @@ +-- ============================================================= +-- ARCHIVO: 57-financial-bank-reconciliation.sql +-- DESCRIPCION: Conciliacion bancaria - extractos, lineas y reglas de match +-- VERSION: 1.0.0 +-- PROYECTO: ERP-Core V2 +-- FECHA: 2026-01-20 +-- DEPENDE DE: 50-financial-schema.sql, 51-financial-accounts.sql, 53-financial-entries.sql +-- ============================================================= + +-- ===================== +-- TIPO ENUMERADO: Estado de extracto bancario +-- ===================== +DO $$ BEGIN + CREATE TYPE financial.bank_statement_status_enum AS ENUM ( + 'draft', -- Borrador + 'reconciling', -- En proceso de conciliacion + 'reconciled' -- Conciliado + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- ===================== +-- TIPO ENUMERADO: Tipo de regla de match +-- ===================== +DO $$ BEGIN + CREATE TYPE financial.reconciliation_match_type_enum AS ENUM ( + 'exact_amount', -- Monto exacto + 'reference_contains', -- Referencia contiene texto + 'partner_name' -- Nombre de partner + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- ===================== +-- TABLA: bank_statements +-- Extractos bancarios importados +-- ===================== +CREATE TABLE IF NOT EXISTS financial.bank_statements ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Multi-tenant + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + company_id UUID, + + -- Cuenta bancaria asociada (referencia a cuenta contable tipo banco) + bank_account_id UUID REFERENCES financial.accounts(id) ON DELETE RESTRICT, + + -- Datos del extracto + statement_date DATE NOT NULL, + opening_balance DECIMAL(15, 2) NOT NULL DEFAULT 0, + closing_balance DECIMAL(15, 2) NOT NULL DEFAULT 0, + + -- Estado + status financial.bank_statement_status_enum DEFAULT 'draft', + + -- Importacion + imported_at TIMESTAMPTZ, + imported_by UUID REFERENCES auth.users(id), + + -- Conciliacion + reconciled_at TIMESTAMPTZ, + reconciled_by UUID REFERENCES auth.users(id), + + -- Audit columns + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + created_by UUID REFERENCES auth.users(id), + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_by UUID REFERENCES auth.users(id) +); + +-- Indices para bank_statements +CREATE INDEX IF NOT EXISTS idx_financial_bank_statements_tenant ON financial.bank_statements(tenant_id); +CREATE INDEX IF NOT EXISTS idx_financial_bank_statements_company ON financial.bank_statements(company_id); +CREATE INDEX IF NOT EXISTS idx_financial_bank_statements_bank_account ON financial.bank_statements(bank_account_id); +CREATE INDEX IF NOT EXISTS idx_financial_bank_statements_date ON financial.bank_statements(statement_date); +CREATE INDEX IF NOT EXISTS idx_financial_bank_statements_status ON financial.bank_statements(status); +CREATE INDEX IF NOT EXISTS idx_financial_bank_statements_tenant_date ON financial.bank_statements(tenant_id, statement_date DESC); +CREATE INDEX IF NOT EXISTS idx_financial_bank_statements_draft ON financial.bank_statements(tenant_id, status) WHERE status = 'draft'; +CREATE INDEX IF NOT EXISTS idx_financial_bank_statements_reconciling ON financial.bank_statements(tenant_id, status) WHERE status = 'reconciling'; + +-- ===================== +-- TABLA: bank_statement_lines +-- Lineas de extracto bancario (movimientos) +-- ===================== +CREATE TABLE IF NOT EXISTS financial.bank_statement_lines ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Relacion con extracto (cascade delete) + statement_id UUID NOT NULL REFERENCES financial.bank_statements(id) ON DELETE CASCADE, + + -- Multi-tenant (denormalizado para queries rapidas) + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + + -- Datos del movimiento + transaction_date DATE NOT NULL, + value_date DATE, + description VARCHAR(500), + reference VARCHAR(100), + amount DECIMAL(15, 2) NOT NULL, -- Positivo = deposito, Negativo = retiro + + -- Estado de conciliacion + is_reconciled BOOLEAN DEFAULT false, + reconciled_entry_id UUID REFERENCES financial.journal_entry_lines(id) ON DELETE SET NULL, + reconciled_at TIMESTAMPTZ, + reconciled_by UUID REFERENCES auth.users(id), + + -- Partner detectado (automatico o manual) + partner_id UUID, -- FK a partners si existe + + -- Notas adicionales + notes TEXT, + + -- Audit columns + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +-- Indices para bank_statement_lines +CREATE INDEX IF NOT EXISTS idx_financial_bank_statement_lines_statement ON financial.bank_statement_lines(statement_id); +CREATE INDEX IF NOT EXISTS idx_financial_bank_statement_lines_tenant ON financial.bank_statement_lines(tenant_id); +CREATE INDEX IF NOT EXISTS idx_financial_bank_statement_lines_date ON financial.bank_statement_lines(transaction_date); +CREATE INDEX IF NOT EXISTS idx_financial_bank_statement_lines_reconciled ON financial.bank_statement_lines(is_reconciled); +CREATE INDEX IF NOT EXISTS idx_financial_bank_statement_lines_entry ON financial.bank_statement_lines(reconciled_entry_id) WHERE reconciled_entry_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_financial_bank_statement_lines_partner ON financial.bank_statement_lines(partner_id) WHERE partner_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_financial_bank_statement_lines_reference ON financial.bank_statement_lines(reference) WHERE reference IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_financial_bank_statement_lines_unreconciled ON financial.bank_statement_lines(tenant_id, statement_id) WHERE is_reconciled = false; +CREATE INDEX IF NOT EXISTS idx_financial_bank_statement_lines_amount ON financial.bank_statement_lines(amount); + +-- ===================== +-- TABLA: bank_reconciliation_rules +-- Reglas de conciliacion automatica +-- ===================== +CREATE TABLE IF NOT EXISTS financial.bank_reconciliation_rules ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Multi-tenant + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + company_id UUID, + + -- Identificacion de la regla + name VARCHAR(255) NOT NULL, + + -- Tipo y valor del match + match_type financial.reconciliation_match_type_enum NOT NULL, + match_value VARCHAR(255) NOT NULL, -- Valor a buscar segun el tipo + + -- Cuenta destino para auto-crear asiento + auto_account_id UUID REFERENCES financial.accounts(id) ON DELETE SET NULL, + + -- Estado y prioridad + is_active BOOLEAN DEFAULT true, + priority INTEGER DEFAULT 0, -- Mayor prioridad = se evalua primero + + -- Audit columns + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + created_by UUID REFERENCES auth.users(id), + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_by UUID REFERENCES auth.users(id) +); + +-- Indices para bank_reconciliation_rules +CREATE INDEX IF NOT EXISTS idx_financial_bank_reconciliation_rules_tenant ON financial.bank_reconciliation_rules(tenant_id); +CREATE INDEX IF NOT EXISTS idx_financial_bank_reconciliation_rules_company ON financial.bank_reconciliation_rules(company_id); +CREATE INDEX IF NOT EXISTS idx_financial_bank_reconciliation_rules_active ON financial.bank_reconciliation_rules(tenant_id, is_active) WHERE is_active = true; +CREATE INDEX IF NOT EXISTS idx_financial_bank_reconciliation_rules_priority ON financial.bank_reconciliation_rules(tenant_id, priority DESC); +CREATE INDEX IF NOT EXISTS idx_financial_bank_reconciliation_rules_match_type ON financial.bank_reconciliation_rules(match_type); + +-- ===================== +-- FUNCION: Calcular balance calculado del extracto +-- Verifica que opening_balance + sum(lines) = closing_balance +-- ===================== +CREATE OR REPLACE FUNCTION financial.check_statement_balance(p_statement_id UUID) +RETURNS BOOLEAN AS $$ +DECLARE + v_opening_balance DECIMAL(15, 2); + v_closing_balance DECIMAL(15, 2); + v_lines_total DECIMAL(15, 2); + v_calculated_closing DECIMAL(15, 2); +BEGIN + -- Obtener balances del extracto + SELECT opening_balance, closing_balance + INTO v_opening_balance, v_closing_balance + FROM financial.bank_statements + WHERE id = p_statement_id; + + -- Sumar todas las lineas + SELECT COALESCE(SUM(amount), 0) + INTO v_lines_total + FROM financial.bank_statement_lines + WHERE statement_id = p_statement_id; + + -- Calcular balance de cierre esperado + v_calculated_closing := v_opening_balance + v_lines_total; + + -- Retornar si coincide (con tolerancia de 0.01) + RETURN ABS(v_calculated_closing - v_closing_balance) < 0.01; +END; +$$ LANGUAGE plpgsql; + +-- ===================== +-- FUNCION: Obtener progreso de conciliacion +-- Retorna porcentaje de lineas conciliadas +-- ===================== +CREATE OR REPLACE FUNCTION financial.get_reconciliation_progress(p_statement_id UUID) +RETURNS NUMERIC AS $$ +DECLARE + v_total_lines INTEGER; + v_reconciled_lines INTEGER; +BEGIN + SELECT COUNT(*), COUNT(*) FILTER (WHERE is_reconciled = true) + INTO v_total_lines, v_reconciled_lines + FROM financial.bank_statement_lines + WHERE statement_id = p_statement_id; + + IF v_total_lines = 0 THEN + RETURN 100; + END IF; + + RETURN ROUND((v_reconciled_lines::NUMERIC / v_total_lines::NUMERIC) * 100, 2); +END; +$$ LANGUAGE plpgsql; + +-- ===================== +-- COMENTARIOS +-- ===================== +COMMENT ON TYPE financial.bank_statement_status_enum IS 'Estado del extracto bancario: borrador, en conciliacion, conciliado'; +COMMENT ON TYPE financial.reconciliation_match_type_enum IS 'Tipo de regla de match: monto exacto, referencia contiene, nombre de partner'; + +COMMENT ON TABLE financial.bank_statements IS 'Extractos bancarios importados para conciliacion'; +COMMENT ON COLUMN financial.bank_statements.bank_account_id IS 'Cuenta contable tipo banco asociada'; +COMMENT ON COLUMN financial.bank_statements.statement_date IS 'Fecha del extracto bancario'; +COMMENT ON COLUMN financial.bank_statements.opening_balance IS 'Saldo inicial del extracto'; +COMMENT ON COLUMN financial.bank_statements.closing_balance IS 'Saldo final del extracto'; +COMMENT ON COLUMN financial.bank_statements.status IS 'Estado: draft, reconciling, reconciled'; +COMMENT ON COLUMN financial.bank_statements.imported_at IS 'Fecha y hora de importacion'; +COMMENT ON COLUMN financial.bank_statements.reconciled_at IS 'Fecha y hora de cierre de conciliacion'; + +COMMENT ON TABLE financial.bank_statement_lines IS 'Lineas/movimientos del extracto bancario'; +COMMENT ON COLUMN financial.bank_statement_lines.transaction_date IS 'Fecha de la transaccion'; +COMMENT ON COLUMN financial.bank_statement_lines.value_date IS 'Fecha valor (cuando aplica el movimiento)'; +COMMENT ON COLUMN financial.bank_statement_lines.amount IS 'Monto del movimiento (positivo=deposito, negativo=retiro)'; +COMMENT ON COLUMN financial.bank_statement_lines.is_reconciled IS 'Indica si la linea ha sido conciliada'; +COMMENT ON COLUMN financial.bank_statement_lines.reconciled_entry_id IS 'Linea de asiento contable con la que se concilio'; +COMMENT ON COLUMN financial.bank_statement_lines.partner_id IS 'Partner detectado o asignado manualmente'; + +COMMENT ON TABLE financial.bank_reconciliation_rules IS 'Reglas para conciliacion automatica de movimientos'; +COMMENT ON COLUMN financial.bank_reconciliation_rules.match_type IS 'Tipo de coincidencia: exact_amount, reference_contains, partner_name'; +COMMENT ON COLUMN financial.bank_reconciliation_rules.match_value IS 'Valor a buscar segun el tipo de match'; +COMMENT ON COLUMN financial.bank_reconciliation_rules.auto_account_id IS 'Cuenta contable para auto-generar asiento'; +COMMENT ON COLUMN financial.bank_reconciliation_rules.priority IS 'Prioridad de evaluacion (mayor = primero)'; + +COMMENT ON FUNCTION financial.check_statement_balance(UUID) IS 'Verifica que el balance calculado coincida con opening + lines = closing'; +COMMENT ON FUNCTION financial.get_reconciliation_progress(UUID) IS 'Retorna porcentaje de lineas conciliadas (0-100)';