From 02ab2caf267731768db4ea7e9310e47600aed6cb Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Thu, 5 Feb 2026 21:52:22 -0600 Subject: [PATCH] [TASK-2026-02-05-EJECUCION-REMEDIATION-ERP-CORE] feat: DDL fixes and new schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 00-auth-base.sql: Extracted auth.tenants+users from recreate-database.sh - 03b-core-companies.sql: DDL for auth.companies entity - 21b-inventory-extended.sql: 7 new tables for inventory entities without DDL - 24-invoices.sql: billing→operations schema to resolve duplication - 27/28/29-cfdi: Track existing CFDI DDL files - recreate-database.sh: Updated ddl_files array (17→43 entries) Co-Authored-By: Claude Opus 4.6 --- ddl/00-auth-base.sql | 52 ++++ ddl/03b-core-companies.sql | 95 ++++++ ddl/21b-inventory-extended.sql | 478 +++++++++++++++++++++++++++++ ddl/24-invoices.sql | 90 +++--- ddl/27-cfdi-core.sql | 460 ++++++++++++++++++++++++++++ ddl/28-cfdi-operations.sql | 442 +++++++++++++++++++++++++++ ddl/29-cfdi-rls-functions.sql | 530 +++++++++++++++++++++++++++++++++ scripts/recreate-database.sh | 32 +- 8 files changed, 2133 insertions(+), 46 deletions(-) create mode 100644 ddl/00-auth-base.sql create mode 100644 ddl/03b-core-companies.sql create mode 100644 ddl/21b-inventory-extended.sql create mode 100644 ddl/27-cfdi-core.sql create mode 100644 ddl/28-cfdi-operations.sql create mode 100644 ddl/29-cfdi-rls-functions.sql diff --git a/ddl/00-auth-base.sql b/ddl/00-auth-base.sql new file mode 100644 index 0000000..ee8a164 --- /dev/null +++ b/ddl/00-auth-base.sql @@ -0,0 +1,52 @@ +-- ============================================================= +-- ARCHIVO: 00-auth-base.sql +-- DESCRIPCION: Tablas base de autenticacion (tenants y users) +-- Estas tablas son prerequisito para todos los demas DDL +-- VERSION: 1.0.0 +-- PROYECTO: ERP-Core V2 +-- FECHA: 2026-02-05 +-- ============================================================= + +-- ===================== +-- SCHEMA: auth (si no existe) +-- ===================== +CREATE SCHEMA IF NOT EXISTS auth; + +-- ===================== +-- TABLA: tenants +-- Organizaciones/empresas en el sistema multi-tenant +-- ===================== +CREATE TABLE IF NOT EXISTS auth.tenants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(200) NOT NULL, + slug VARCHAR(100) UNIQUE NOT NULL, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMPTZ +); + +-- ===================== +-- TABLA: users +-- Usuarios del sistema, vinculados a un tenant +-- ===================== +CREATE TABLE IF NOT EXISTS auth.users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID REFERENCES auth.tenants(id) ON DELETE CASCADE, + email VARCHAR(255) NOT NULL, + password_hash TEXT, + first_name VARCHAR(100), + last_name VARCHAR(100), + is_active BOOLEAN DEFAULT TRUE, + email_verified BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMPTZ, + UNIQUE(tenant_id, email) +); + +-- ===================== +-- INDICES +-- ===================== +CREATE INDEX IF NOT EXISTS idx_users_tenant ON auth.users(tenant_id); +CREATE INDEX IF NOT EXISTS idx_users_email ON auth.users(email); diff --git a/ddl/03b-core-companies.sql b/ddl/03b-core-companies.sql new file mode 100644 index 0000000..9a57e1b --- /dev/null +++ b/ddl/03b-core-companies.sql @@ -0,0 +1,95 @@ +-- ============================================================= +-- ARCHIVO: 03b-core-companies.sql +-- DESCRIPCION: Tabla de empresas/companies del tenant +-- VERSION: 1.0.0 +-- PROYECTO: ERP-Core V2 +-- FECHA: 2026-02-05 +-- DEPENDE DE: 00-auth-base.sql (auth.tenants, auth.users) +-- 20-core-catalogs.sql (core.currencies) +-- 16-partners.sql (partners.partners) +-- NOTA: La entidad TypeORM usa schema 'auth'. Las FK en +-- 45-hr.sql apuntan a core.companies (inconsistencia +-- conocida a corregir en tarea separada). +-- ============================================================= + +-- ===================== +-- SCHEMA: auth (si no existe) +-- ===================== +CREATE SCHEMA IF NOT EXISTS auth; + +-- ===================== +-- TABLA: companies +-- Empresas registradas dentro de un tenant (multi-empresa) +-- Soporta jerarquia de empresas (parent_company_id) +-- ===================== +CREATE TABLE IF NOT EXISTS auth.companies ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + + -- Identificacion + name VARCHAR(255) NOT NULL, + legal_name VARCHAR(255), + tax_id VARCHAR(50), + + -- Relaciones + currency_id UUID REFERENCES core.currencies(id) ON DELETE SET NULL, + parent_company_id UUID REFERENCES auth.companies(id) ON DELETE SET NULL, + partner_id UUID REFERENCES partners.partners(id) ON DELETE SET NULL, + + -- Configuracion + settings JSONB DEFAULT '{}', + + -- Auditoria + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, + deleted_at TIMESTAMPTZ, + deleted_by UUID REFERENCES auth.users(id) ON DELETE SET NULL +); + +-- ===================== +-- INDICES +-- ===================== +CREATE INDEX IF NOT EXISTS idx_companies_tenant_id ON auth.companies(tenant_id); +CREATE INDEX IF NOT EXISTS idx_companies_parent_company_id ON auth.companies(parent_company_id); +CREATE INDEX IF NOT EXISTS idx_companies_active ON auth.companies(tenant_id) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_companies_tax_id ON auth.companies(tax_id); +CREATE INDEX IF NOT EXISTS idx_companies_currency ON auth.companies(currency_id); +CREATE INDEX IF NOT EXISTS idx_companies_partner ON auth.companies(partner_id); + +-- ===================== +-- RLS POLICIES +-- ===================== +ALTER TABLE auth.companies ENABLE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation_companies ON auth.companies + USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); + +-- ===================== +-- TRIGGER: updated_at automatico +-- ===================== +CREATE OR REPLACE FUNCTION auth.update_companies_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at := CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_companies_updated_at + BEFORE UPDATE ON auth.companies + FOR EACH ROW + EXECUTE FUNCTION auth.update_companies_updated_at(); + +-- ===================== +-- COMENTARIOS +-- ===================== +COMMENT ON TABLE auth.companies IS 'Empresas registradas dentro de un tenant. Soporta jerarquia via parent_company_id.'; +COMMENT ON COLUMN auth.companies.tenant_id IS 'Tenant al que pertenece la empresa'; +COMMENT ON COLUMN auth.companies.name IS 'Nombre comercial de la empresa'; +COMMENT ON COLUMN auth.companies.legal_name IS 'Razon social / nombre legal'; +COMMENT ON COLUMN auth.companies.tax_id IS 'RFC o identificador fiscal'; +COMMENT ON COLUMN auth.companies.currency_id IS 'Moneda principal de la empresa (ref core.currencies)'; +COMMENT ON COLUMN auth.companies.parent_company_id IS 'Empresa padre para jerarquia corporativa'; +COMMENT ON COLUMN auth.companies.partner_id IS 'Partner asociado a la empresa (ref partners.partners)'; +COMMENT ON COLUMN auth.companies.settings IS 'Configuraciones especificas de la empresa en formato JSON'; diff --git a/ddl/21b-inventory-extended.sql b/ddl/21b-inventory-extended.sql new file mode 100644 index 0000000..d762f2c --- /dev/null +++ b/ddl/21b-inventory-extended.sql @@ -0,0 +1,478 @@ +-- ============================================================= +-- ARCHIVO: 21b-inventory-extended.sql +-- DESCRIPCION: Tablas extendidas de inventario (Odoo-style) +-- Incluye: locations, products (inventory-specific), stock_quants, +-- stock_moves, stock_valuation_layers, inventory_adjustments, +-- inventory_adjustment_lines +-- VERSION: 1.0.0 +-- PROYECTO: ERP-Core V2 +-- FECHA: 2026-02-05 +-- DEPENDE DE: 18-warehouses.sql, 21-inventory.sql +-- COHERENCIA: Entidades TypeORM sin DDL previo +-- ============================================================= + +-- ===================== +-- SCHEMA: inventory (ya creado en 18-warehouses.sql) +-- ===================== + +-- ===================== +-- TIPO ENUM: location_type +-- Tipos de ubicacion Odoo-style +-- ===================== +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'location_type_enum') THEN + CREATE TYPE inventory.location_type_enum AS ENUM ( + 'internal', + 'supplier', + 'customer', + 'inventory', + 'production', + 'transit' + ); + END IF; +END$$; + +-- ===================== +-- TIPO ENUM: product_type +-- Tipos de producto para inventario +-- ===================== +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'inv_product_type_enum') THEN + CREATE TYPE inventory.inv_product_type_enum AS ENUM ( + 'storable', + 'consumable', + 'service' + ); + END IF; +END$$; + +-- ===================== +-- TIPO ENUM: tracking_type +-- Tipo de seguimiento de producto +-- ===================== +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'tracking_type_enum') THEN + CREATE TYPE inventory.tracking_type_enum AS ENUM ( + 'none', + 'lot', + 'serial' + ); + END IF; +END$$; + +-- ===================== +-- TIPO ENUM: valuation_method +-- Metodo de valuacion de inventario +-- ===================== +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'valuation_method_enum') THEN + CREATE TYPE inventory.valuation_method_enum AS ENUM ( + 'standard', + 'fifo', + 'average' + ); + END IF; +END$$; + +-- ===================== +-- TIPO ENUM: adjustment_status +-- Estado de ajuste de inventario +-- ===================== +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'adjustment_status_enum') THEN + CREATE TYPE inventory.adjustment_status_enum AS ENUM ( + 'draft', + 'confirmed', + 'done', + 'cancelled' + ); + END IF; +END$$; + +-- ===================== +-- TABLA: locations +-- Ubicaciones logicas de inventario (Odoo-style) +-- Complementa warehouse_locations con modelo Odoo de ubicaciones virtuales +-- (supplier, customer, inventory loss, production, transit) +-- Coherencia: location.entity.ts +-- ===================== +CREATE TABLE IF NOT EXISTS inventory.locations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + warehouse_id UUID REFERENCES inventory.warehouses(id) ON DELETE SET NULL, + + -- Identificacion + name VARCHAR(255) NOT NULL, + complete_name VARCHAR(500), + + -- Tipo de ubicacion + location_type inventory.location_type_enum NOT NULL, + + -- Jerarquia (auto-referencia) + parent_id UUID REFERENCES inventory.locations(id) ON DELETE SET NULL, + + -- Flags especiales + is_scrap_location BOOLEAN NOT NULL DEFAULT FALSE, + is_return_location BOOLEAN NOT NULL DEFAULT FALSE, + + -- Estado + active BOOLEAN NOT NULL DEFAULT TRUE, + + -- Metadata + 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 locations +CREATE INDEX IF NOT EXISTS idx_locations_tenant_id ON inventory.locations(tenant_id); +CREATE INDEX IF NOT EXISTS idx_locations_warehouse_id ON inventory.locations(warehouse_id) WHERE warehouse_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_locations_parent_id ON inventory.locations(parent_id) WHERE parent_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_locations_type ON inventory.locations(location_type); +CREATE INDEX IF NOT EXISTS idx_locations_active ON inventory.locations(active) WHERE active = TRUE; +CREATE INDEX IF NOT EXISTS idx_locations_scrap ON inventory.locations(is_scrap_location) WHERE is_scrap_location = TRUE; +CREATE INDEX IF NOT EXISTS idx_locations_return ON inventory.locations(is_return_location) WHERE is_return_location = TRUE; + +-- ===================== +-- TABLA: products +-- Productos especificos de inventario (Odoo-style) +-- Diferente de products.products: este es para gestion de almacen, +-- valuacion, tracking por lote/serie, mientras products.products +-- es para comercio/retail con codigos SAT, impuestos, etc. +-- Coherencia: product.entity.ts (inventory module) +-- ===================== +CREATE TABLE IF NOT EXISTS inventory.products ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + + -- Identificacion + name VARCHAR(255) NOT NULL, + code VARCHAR(100), + barcode VARCHAR(100), + description TEXT, + + -- Clasificacion + product_type inventory.inv_product_type_enum NOT NULL DEFAULT 'storable', + tracking inventory.tracking_type_enum NOT NULL DEFAULT 'none', + category_id UUID, + + -- Unidades de medida + uom_id UUID NOT NULL, + purchase_uom_id UUID, + + -- Precios y costo + cost_price DECIMAL(12, 2) NOT NULL DEFAULT 0, + list_price DECIMAL(12, 2) NOT NULL DEFAULT 0, + + -- Valuacion + valuation_method inventory.valuation_method_enum NOT NULL DEFAULT 'fifo', + + -- Flags + is_storable BOOLEAN NOT NULL DEFAULT TRUE, + can_be_sold BOOLEAN NOT NULL DEFAULT TRUE, + can_be_purchased BOOLEAN NOT NULL DEFAULT TRUE, + active BOOLEAN NOT NULL DEFAULT TRUE, + + -- Medidas fisicas + weight DECIMAL(12, 4), + volume DECIMAL(12, 4), + + -- Imagen + image_url VARCHAR(500), + + -- Metadata + 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, + deleted_by UUID REFERENCES auth.users(id) +); + +-- Indices para products +CREATE INDEX IF NOT EXISTS idx_inv_products_tenant_id ON inventory.products(tenant_id); +CREATE INDEX IF NOT EXISTS idx_inv_products_code ON inventory.products(code) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_inv_products_barcode ON inventory.products(barcode) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_inv_products_category_id ON inventory.products(category_id) WHERE category_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_inv_products_active ON inventory.products(active) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_inv_products_type ON inventory.products(product_type); +CREATE INDEX IF NOT EXISTS idx_inv_products_tracking ON inventory.products(tracking) WHERE tracking != 'none'; +CREATE INDEX IF NOT EXISTS idx_inv_products_valuation ON inventory.products(valuation_method); + +-- Unique code per tenant (soft-delete aware) +CREATE UNIQUE INDEX IF NOT EXISTS uq_inv_products_tenant_code + ON inventory.products(tenant_id, code) WHERE deleted_at IS NULL AND code IS NOT NULL; + +-- ===================== +-- TABLA: stock_quants +-- Cantidades de stock por producto/ubicacion/lote (modelo Odoo) +-- Representa la cantidad real de un producto en una ubicacion especifica, +-- opcionalmente separada por lote +-- Coherencia: stock-quant.entity.ts +-- ===================== +CREATE TABLE IF NOT EXISTS inventory.stock_quants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + + -- Producto y ubicacion + product_id UUID NOT NULL REFERENCES inventory.products(id) ON DELETE CASCADE, + location_id UUID NOT NULL REFERENCES inventory.locations(id) ON DELETE CASCADE, + + -- Lote (opcional) + lot_id UUID REFERENCES inventory.lots(id) ON DELETE SET NULL, + + -- Cantidades + quantity DECIMAL(16, 4) NOT NULL DEFAULT 0, + reserved_quantity DECIMAL(16, 4) NOT NULL DEFAULT 0, + + -- Metadata + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + + -- Unicidad: un solo registro por combinacion producto+ubicacion+lote + UNIQUE(product_id, location_id, COALESCE(lot_id, '00000000-0000-0000-0000-000000000000'::UUID)) +); + +-- Indices para stock_quants +CREATE INDEX IF NOT EXISTS idx_stock_quants_tenant_id ON inventory.stock_quants(tenant_id); +CREATE INDEX IF NOT EXISTS idx_stock_quants_product_id ON inventory.stock_quants(product_id); +CREATE INDEX IF NOT EXISTS idx_stock_quants_location_id ON inventory.stock_quants(location_id); +CREATE INDEX IF NOT EXISTS idx_stock_quants_lot_id ON inventory.stock_quants(lot_id) WHERE lot_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_stock_quants_quantity ON inventory.stock_quants(quantity) WHERE quantity != 0; +CREATE INDEX IF NOT EXISTS idx_stock_quants_reserved ON inventory.stock_quants(reserved_quantity) WHERE reserved_quantity != 0; + +-- ===================== +-- TABLA: stock_moves +-- Movimientos individuales de stock vinculados a un picking (Odoo-style) +-- Cada linea de un picking genera un stock_move +-- Coherencia: stock-move.entity.ts +-- ===================== +CREATE TABLE IF NOT EXISTS inventory.stock_moves ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + + -- Picking padre + picking_id UUID NOT NULL REFERENCES inventory.pickings(id) ON DELETE CASCADE, + + -- Producto + product_id UUID NOT NULL REFERENCES inventory.products(id) ON DELETE RESTRICT, + product_uom_id UUID NOT NULL, + + -- Ubicaciones origen y destino + location_id UUID NOT NULL REFERENCES inventory.locations(id), + location_dest_id UUID NOT NULL REFERENCES inventory.locations(id), + + -- Cantidades + product_qty DECIMAL(16, 4) NOT NULL, + quantity_done DECIMAL(16, 4) NOT NULL DEFAULT 0, + + -- Lote (opcional) + lot_id UUID REFERENCES inventory.lots(id) ON DELETE SET NULL, + + -- Estado (reutiliza move_status_enum de 21-inventory.sql) + status inventory.move_status_enum NOT NULL DEFAULT 'draft', + + -- Fecha del movimiento + date TIMESTAMPTZ, + + -- Documento origen + origin VARCHAR(255), + + -- Metadata + 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 stock_moves +CREATE INDEX IF NOT EXISTS idx_stock_moves_tenant_id ON inventory.stock_moves(tenant_id); +CREATE INDEX IF NOT EXISTS idx_stock_moves_picking_id ON inventory.stock_moves(picking_id); +CREATE INDEX IF NOT EXISTS idx_stock_moves_product_id ON inventory.stock_moves(product_id); +CREATE INDEX IF NOT EXISTS idx_stock_moves_status ON inventory.stock_moves(status); +CREATE INDEX IF NOT EXISTS idx_stock_moves_date ON inventory.stock_moves(date) WHERE date IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_stock_moves_location ON inventory.stock_moves(location_id); +CREATE INDEX IF NOT EXISTS idx_stock_moves_location_dest ON inventory.stock_moves(location_dest_id); +CREATE INDEX IF NOT EXISTS idx_stock_moves_lot_id ON inventory.stock_moves(lot_id) WHERE lot_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_stock_moves_origin ON inventory.stock_moves(origin) WHERE origin IS NOT NULL; + +-- ===================== +-- TABLA: stock_valuation_layers +-- Capas de valuacion de inventario (FIFO/AVCO/Standard) +-- Cada movimiento de stock genera una capa de valuacion para +-- rastrear el costo historico del inventario +-- Coherencia: stock-valuation-layer.entity.ts +-- ===================== +CREATE TABLE IF NOT EXISTS inventory.stock_valuation_layers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + + -- Producto y empresa + product_id UUID NOT NULL REFERENCES inventory.products(id) ON DELETE RESTRICT, + company_id UUID NOT NULL REFERENCES auth.companies(id) ON DELETE CASCADE, + + -- Cantidades y valores + quantity DECIMAL(16, 4) NOT NULL, + unit_cost DECIMAL(12, 2) NOT NULL, + value DECIMAL(16, 2) NOT NULL, + + -- Remanente (para FIFO) + remaining_qty DECIMAL(16, 4) NOT NULL, + remaining_value DECIMAL(16, 2) NOT NULL, + + -- Movimiento de stock origen + stock_move_id UUID REFERENCES inventory.stock_moves(id) ON DELETE SET NULL, + + -- Descripcion + description VARCHAR(255), + + -- Contabilidad (referencia a asientos contables) + account_move_id UUID, + journal_entry_id UUID, + + -- Metadata + 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 stock_valuation_layers +CREATE INDEX IF NOT EXISTS idx_valuation_layers_tenant_id ON inventory.stock_valuation_layers(tenant_id); +CREATE INDEX IF NOT EXISTS idx_valuation_layers_product_id ON inventory.stock_valuation_layers(product_id); +CREATE INDEX IF NOT EXISTS idx_valuation_layers_company_id ON inventory.stock_valuation_layers(company_id); +CREATE INDEX IF NOT EXISTS idx_valuation_layers_stock_move_id ON inventory.stock_valuation_layers(stock_move_id) WHERE stock_move_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_valuation_layers_remaining_qty ON inventory.stock_valuation_layers(remaining_qty) WHERE remaining_qty > 0; +CREATE INDEX IF NOT EXISTS idx_valuation_layers_account_move ON inventory.stock_valuation_layers(account_move_id) WHERE account_move_id IS NOT NULL; + +-- ===================== +-- TABLA: inventory_adjustments +-- Ajustes de inventario (conteos y correcciones) +-- Agrupa lineas de ajuste para una ubicacion y fecha especifica +-- Coherencia: inventory-adjustment.entity.ts +-- ===================== +CREATE TABLE IF NOT EXISTS inventory.inventory_adjustments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + company_id UUID NOT NULL REFERENCES auth.companies(id) ON DELETE CASCADE, + + -- Identificacion + name VARCHAR(100) NOT NULL, + + -- Ubicacion de ajuste + location_id UUID NOT NULL REFERENCES inventory.locations(id), + + -- Fecha del ajuste + date DATE NOT NULL, + + -- Estado + status inventory.adjustment_status_enum NOT NULL DEFAULT 'draft', + + -- Notas + notes TEXT, + + -- Metadata + 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 inventory_adjustments +CREATE INDEX IF NOT EXISTS idx_adjustments_tenant_id ON inventory.inventory_adjustments(tenant_id); +CREATE INDEX IF NOT EXISTS idx_adjustments_company_id ON inventory.inventory_adjustments(company_id); +CREATE INDEX IF NOT EXISTS idx_adjustments_status ON inventory.inventory_adjustments(status); +CREATE INDEX IF NOT EXISTS idx_adjustments_date ON inventory.inventory_adjustments(date); +CREATE INDEX IF NOT EXISTS idx_adjustments_location ON inventory.inventory_adjustments(location_id); + +-- ===================== +-- TABLA: inventory_adjustment_lines +-- Lineas individuales de ajuste de inventario +-- Cada linea compara cantidad teorica vs contada para un producto +-- Coherencia: inventory-adjustment-line.entity.ts +-- ===================== +CREATE TABLE IF NOT EXISTS inventory.inventory_adjustment_lines ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + adjustment_id UUID NOT NULL REFERENCES inventory.inventory_adjustments(id) ON DELETE CASCADE, + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + + -- Producto y ubicacion + product_id UUID NOT NULL REFERENCES inventory.products(id) ON DELETE RESTRICT, + location_id UUID NOT NULL REFERENCES inventory.locations(id), + + -- Lote (opcional) + lot_id UUID REFERENCES inventory.lots(id) ON DELETE SET NULL, + + -- Cantidades + theoretical_qty DECIMAL(16, 4) NOT NULL DEFAULT 0, + counted_qty DECIMAL(16, 4) NOT NULL DEFAULT 0, + difference_qty DECIMAL(16, 4) GENERATED ALWAYS AS (counted_qty - theoretical_qty) STORED, + + -- Unidad de medida + uom_id UUID, + + -- Notas + notes TEXT, + + -- Metadata + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +-- Indices para inventory_adjustment_lines +CREATE INDEX IF NOT EXISTS idx_adjustment_lines_adjustment_id ON inventory.inventory_adjustment_lines(adjustment_id); +CREATE INDEX IF NOT EXISTS idx_adjustment_lines_product_id ON inventory.inventory_adjustment_lines(product_id); +CREATE INDEX IF NOT EXISTS idx_adjustment_lines_location_id ON inventory.inventory_adjustment_lines(location_id); +CREATE INDEX IF NOT EXISTS idx_adjustment_lines_lot_id ON inventory.inventory_adjustment_lines(lot_id) WHERE lot_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_adjustment_lines_tenant_id ON inventory.inventory_adjustment_lines(tenant_id); + +-- ===================== +-- COMENTARIOS +-- ===================== + +-- locations +COMMENT ON TABLE inventory.locations IS 'Ubicaciones logicas de inventario (Odoo-style). Incluye ubicaciones virtuales: supplier, customer, inventory loss, production, transit'; +COMMENT ON COLUMN inventory.locations.location_type IS 'Tipo: internal (almacen), supplier (proveedor virtual), customer (cliente virtual), inventory (perdida/ajuste), production (produccion), transit (en transito)'; +COMMENT ON COLUMN inventory.locations.complete_name IS 'Nombre completo con jerarquia (ej: WH/Stock/Zone-A)'; +COMMENT ON COLUMN inventory.locations.is_scrap_location IS 'Si es TRUE, productos movidos aqui se consideran scrap/desperdicio'; +COMMENT ON COLUMN inventory.locations.is_return_location IS 'Si es TRUE, esta ubicacion se usa para devoluciones'; + +-- products (inventory) +COMMENT ON TABLE inventory.products IS 'Productos para gestion de inventario (Odoo-style). Diferente de products.products que es para comercio/retail'; +COMMENT ON COLUMN inventory.products.product_type IS 'Tipo: storable (se almacena), consumable (se consume sin tracking), service (no se almacena)'; +COMMENT ON COLUMN inventory.products.tracking IS 'Seguimiento: none, lot (por lote), serial (numero de serie unico)'; +COMMENT ON COLUMN inventory.products.valuation_method IS 'Metodo de valuacion: standard (costo fijo), fifo (primero en entrar), average (costo promedio)'; +COMMENT ON COLUMN inventory.products.is_storable IS 'Flag derivado de product_type=storable. Facilita filtros directos'; + +-- stock_quants +COMMENT ON TABLE inventory.stock_quants IS 'Cantidades reales de stock por producto/ubicacion/lote (modelo Odoo quant)'; +COMMENT ON COLUMN inventory.stock_quants.quantity IS 'Cantidad total disponible en esta ubicacion'; +COMMENT ON COLUMN inventory.stock_quants.reserved_quantity IS 'Cantidad reservada para pickings pendientes'; + +-- stock_moves +COMMENT ON TABLE inventory.stock_moves IS 'Movimientos individuales de stock vinculados a un picking. Cada linea de picking genera un move'; +COMMENT ON COLUMN inventory.stock_moves.product_qty IS 'Cantidad solicitada del movimiento'; +COMMENT ON COLUMN inventory.stock_moves.quantity_done IS 'Cantidad realmente procesada'; +COMMENT ON COLUMN inventory.stock_moves.status IS 'Estado: draft, waiting, confirmed, assigned, done, cancelled'; +COMMENT ON COLUMN inventory.stock_moves.origin IS 'Documento origen (ej: PO-2026-001, SO-2026-001)'; + +-- stock_valuation_layers +COMMENT ON TABLE inventory.stock_valuation_layers IS 'Capas de valuacion de inventario para FIFO/AVCO/Standard. Cada movimiento crea una capa'; +COMMENT ON COLUMN inventory.stock_valuation_layers.remaining_qty IS 'Cantidad restante en esta capa (para FIFO: se consume de las capas mas antiguas)'; +COMMENT ON COLUMN inventory.stock_valuation_layers.remaining_value IS 'Valor monetario restante en esta capa'; +COMMENT ON COLUMN inventory.stock_valuation_layers.account_move_id IS 'Referencia al asiento contable generado (si aplica)'; + +-- inventory_adjustments +COMMENT ON TABLE inventory.inventory_adjustments IS 'Ajustes de inventario para corregir diferencias entre stock teorico y real'; +COMMENT ON COLUMN inventory.inventory_adjustments.status IS 'Estado: draft (borrador), confirmed (confirmado), done (aplicado), cancelled'; + +-- inventory_adjustment_lines +COMMENT ON TABLE inventory.inventory_adjustment_lines IS 'Lineas de ajuste: cada una compara cantidad teorica vs contada para un producto en una ubicacion'; +COMMENT ON COLUMN inventory.inventory_adjustment_lines.theoretical_qty IS 'Cantidad segun el sistema antes del conteo'; +COMMENT ON COLUMN inventory.inventory_adjustment_lines.counted_qty IS 'Cantidad real contada fisicamente'; +COMMENT ON COLUMN inventory.inventory_adjustment_lines.difference_qty IS 'Diferencia calculada: counted_qty - theoretical_qty (columna generada)'; diff --git a/ddl/24-invoices.sql b/ddl/24-invoices.sql index e583f91..8830c7b 100644 --- a/ddl/24-invoices.sql +++ b/ddl/24-invoices.sql @@ -8,15 +8,15 @@ -- ============================================================= -- ===================== --- SCHEMA: billing +-- SCHEMA: operations -- ===================== -CREATE SCHEMA IF NOT EXISTS billing; +CREATE SCHEMA IF NOT EXISTS operations; -- ===================== -- TABLA: invoices -- Facturas de venta y compra -- ===================== -CREATE TABLE IF NOT EXISTS billing.invoices ( +CREATE TABLE IF NOT EXISTS operations.invoices ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, @@ -82,25 +82,25 @@ CREATE TABLE IF NOT EXISTS billing.invoices ( ); -- Indices para invoices -CREATE INDEX IF NOT EXISTS idx_invoices_tenant ON billing.invoices(tenant_id); -CREATE INDEX IF NOT EXISTS idx_invoices_number ON billing.invoices(invoice_number); -CREATE INDEX IF NOT EXISTS idx_invoices_type ON billing.invoices(invoice_type); -CREATE INDEX IF NOT EXISTS idx_invoices_partner ON billing.invoices(partner_id); -CREATE INDEX IF NOT EXISTS idx_invoices_sales_order ON billing.invoices(sales_order_id); -CREATE INDEX IF NOT EXISTS idx_invoices_purchase_order ON billing.invoices(purchase_order_id); -CREATE INDEX IF NOT EXISTS idx_invoices_status ON billing.invoices(status); -CREATE INDEX IF NOT EXISTS idx_invoices_date ON billing.invoices(invoice_date); -CREATE INDEX IF NOT EXISTS idx_invoices_due_date ON billing.invoices(due_date); -CREATE INDEX IF NOT EXISTS idx_invoices_cfdi ON billing.invoices(cfdi_uuid); -CREATE INDEX IF NOT EXISTS idx_invoices_unpaid ON billing.invoices(status) WHERE status IN ('validated', 'sent', 'partial'); +CREATE INDEX IF NOT EXISTS idx_invoices_tenant ON operations.invoices(tenant_id); +CREATE INDEX IF NOT EXISTS idx_invoices_number ON operations.invoices(invoice_number); +CREATE INDEX IF NOT EXISTS idx_invoices_type ON operations.invoices(invoice_type); +CREATE INDEX IF NOT EXISTS idx_invoices_partner ON operations.invoices(partner_id); +CREATE INDEX IF NOT EXISTS idx_invoices_sales_order ON operations.invoices(sales_order_id); +CREATE INDEX IF NOT EXISTS idx_invoices_purchase_order ON operations.invoices(purchase_order_id); +CREATE INDEX IF NOT EXISTS idx_invoices_status ON operations.invoices(status); +CREATE INDEX IF NOT EXISTS idx_invoices_date ON operations.invoices(invoice_date); +CREATE INDEX IF NOT EXISTS idx_invoices_due_date ON operations.invoices(due_date); +CREATE INDEX IF NOT EXISTS idx_invoices_cfdi ON operations.invoices(cfdi_uuid); +CREATE INDEX IF NOT EXISTS idx_invoices_unpaid ON operations.invoices(status) WHERE status IN ('validated', 'sent', 'partial'); -- ===================== -- TABLA: invoice_items -- Lineas de factura -- ===================== -CREATE TABLE IF NOT EXISTS billing.invoice_items ( +CREATE TABLE IF NOT EXISTS operations.invoice_items ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - invoice_id UUID NOT NULL REFERENCES billing.invoices(id) ON DELETE CASCADE, + invoice_id UUID NOT NULL REFERENCES operations.invoices(id) ON DELETE CASCADE, product_id UUID REFERENCES products.products(id) ON DELETE SET NULL, -- Linea @@ -140,15 +140,15 @@ CREATE TABLE IF NOT EXISTS billing.invoice_items ( ); -- Indices para invoice_items -CREATE INDEX IF NOT EXISTS idx_invoice_items_invoice ON billing.invoice_items(invoice_id); -CREATE INDEX IF NOT EXISTS idx_invoice_items_product ON billing.invoice_items(product_id); -CREATE INDEX IF NOT EXISTS idx_invoice_items_line ON billing.invoice_items(invoice_id, line_number); +CREATE INDEX IF NOT EXISTS idx_invoice_items_invoice ON operations.invoice_items(invoice_id); +CREATE INDEX IF NOT EXISTS idx_invoice_items_product ON operations.invoice_items(product_id); +CREATE INDEX IF NOT EXISTS idx_invoice_items_line ON operations.invoice_items(invoice_id, line_number); -- ===================== -- TABLA: payments -- Pagos recibidos y realizados -- ===================== -CREATE TABLE IF NOT EXISTS billing.payments ( +CREATE TABLE IF NOT EXISTS operations.payments ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, @@ -195,22 +195,22 @@ CREATE TABLE IF NOT EXISTS billing.payments ( ); -- Indices para payments -CREATE INDEX IF NOT EXISTS idx_payments_tenant ON billing.payments(tenant_id); -CREATE INDEX IF NOT EXISTS idx_payments_number ON billing.payments(payment_number); -CREATE INDEX IF NOT EXISTS idx_payments_type ON billing.payments(payment_type); -CREATE INDEX IF NOT EXISTS idx_payments_partner ON billing.payments(partner_id); -CREATE INDEX IF NOT EXISTS idx_payments_status ON billing.payments(status); -CREATE INDEX IF NOT EXISTS idx_payments_date ON billing.payments(payment_date); -CREATE INDEX IF NOT EXISTS idx_payments_method ON billing.payments(payment_method); +CREATE INDEX IF NOT EXISTS idx_payments_tenant ON operations.payments(tenant_id); +CREATE INDEX IF NOT EXISTS idx_payments_number ON operations.payments(payment_number); +CREATE INDEX IF NOT EXISTS idx_payments_type ON operations.payments(payment_type); +CREATE INDEX IF NOT EXISTS idx_payments_partner ON operations.payments(partner_id); +CREATE INDEX IF NOT EXISTS idx_payments_status ON operations.payments(status); +CREATE INDEX IF NOT EXISTS idx_payments_date ON operations.payments(payment_date); +CREATE INDEX IF NOT EXISTS idx_payments_method ON operations.payments(payment_method); -- ===================== -- TABLA: payment_allocations -- Aplicacion de pagos a facturas -- ===================== -CREATE TABLE IF NOT EXISTS billing.payment_allocations ( +CREATE TABLE IF NOT EXISTS operations.payment_allocations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - payment_id UUID NOT NULL REFERENCES billing.payments(id) ON DELETE CASCADE, - invoice_id UUID NOT NULL REFERENCES billing.invoices(id) ON DELETE CASCADE, + payment_id UUID NOT NULL REFERENCES operations.payments(id) ON DELETE CASCADE, + invoice_id UUID NOT NULL REFERENCES operations.invoices(id) ON DELETE CASCADE, -- Monto aplicado amount DECIMAL(15, 2) NOT NULL, @@ -226,25 +226,25 @@ CREATE TABLE IF NOT EXISTS billing.payment_allocations ( ); -- Indices para payment_allocations -CREATE INDEX IF NOT EXISTS idx_payment_allocations_payment ON billing.payment_allocations(payment_id); -CREATE INDEX IF NOT EXISTS idx_payment_allocations_invoice ON billing.payment_allocations(invoice_id); +CREATE INDEX IF NOT EXISTS idx_payment_allocations_payment ON operations.payment_allocations(payment_id); +CREATE INDEX IF NOT EXISTS idx_payment_allocations_invoice ON operations.payment_allocations(invoice_id); -- ===================== -- COMENTARIOS -- ===================== -COMMENT ON TABLE billing.invoices IS 'Facturas de venta y compra'; -COMMENT ON COLUMN billing.invoices.invoice_type IS 'Tipo: sale (venta), purchase (compra), credit_note (nota credito), debit_note (nota debito)'; -COMMENT ON COLUMN billing.invoices.status IS 'Estado: draft, validated, sent, partial (pago parcial), paid, cancelled, voided'; -COMMENT ON COLUMN billing.invoices.cfdi_uuid IS 'UUID del CFDI para facturacion electronica en Mexico'; -COMMENT ON COLUMN billing.invoices.amount_due IS 'Saldo pendiente de pago (calculado)'; +COMMENT ON TABLE operations.invoices IS 'Facturas de venta y compra'; +COMMENT ON COLUMN operations.invoices.invoice_type IS 'Tipo: sale (venta), purchase (compra), credit_note (nota credito), debit_note (nota debito)'; +COMMENT ON COLUMN operations.invoices.status IS 'Estado: draft, validated, sent, partial (pago parcial), paid, cancelled, voided'; +COMMENT ON COLUMN operations.invoices.cfdi_uuid IS 'UUID del CFDI para facturacion electronica en Mexico'; +COMMENT ON COLUMN operations.invoices.amount_due IS 'Saldo pendiente de pago (calculado)'; -COMMENT ON TABLE billing.invoice_items IS 'Lineas de detalle de facturas'; -COMMENT ON COLUMN billing.invoice_items.sat_product_code IS 'Clave de producto del catalogo SAT (Mexico)'; -COMMENT ON COLUMN billing.invoice_items.sat_unit_code IS 'Clave de unidad del catalogo SAT (Mexico)'; +COMMENT ON TABLE operations.invoice_items IS 'Lineas de detalle de facturas'; +COMMENT ON COLUMN operations.invoice_items.sat_product_code IS 'Clave de producto del catalogo SAT (Mexico)'; +COMMENT ON COLUMN operations.invoice_items.sat_unit_code IS 'Clave de unidad del catalogo SAT (Mexico)'; -COMMENT ON TABLE billing.payments IS 'Registro de pagos recibidos y realizados'; -COMMENT ON COLUMN billing.payments.payment_type IS 'Tipo: received (cobro a cliente), made (pago a proveedor)'; -COMMENT ON COLUMN billing.payments.status IS 'Estado: draft, confirmed, reconciled, cancelled'; +COMMENT ON TABLE operations.payments IS 'Registro de pagos recibidos y realizados'; +COMMENT ON COLUMN operations.payments.payment_type IS 'Tipo: received (cobro a cliente), made (pago a proveedor)'; +COMMENT ON COLUMN operations.payments.status IS 'Estado: draft, confirmed, reconciled, cancelled'; -COMMENT ON TABLE billing.payment_allocations IS 'Aplicacion de pagos a facturas especificas'; -COMMENT ON COLUMN billing.payment_allocations.amount IS 'Monto del pago aplicado a esta factura'; +COMMENT ON TABLE operations.payment_allocations IS 'Aplicacion de pagos a facturas especificas'; +COMMENT ON COLUMN operations.payment_allocations.amount IS 'Monto del pago aplicado a esta factura'; diff --git a/ddl/27-cfdi-core.sql b/ddl/27-cfdi-core.sql new file mode 100644 index 0000000..63c8a81 --- /dev/null +++ b/ddl/27-cfdi-core.sql @@ -0,0 +1,460 @@ +-- ============================================================= +-- ARCHIVO: 27-cfdi-core.sql +-- DESCRIPCION: Modulo CFDI - Facturacion electronica SAT Mexico +-- Certificados CSD, tipos, extension de facturas +-- VERSION: 1.0.0 +-- PROYECTO: ERP-Core V2 +-- FECHA: 2026-02-03 +-- DEPENDE DE: 01-auth-profiles.sql, 24-invoices.sql, 26-fiscal-catalogs.sql +-- ============================================================= + +-- ===================== +-- NOTA: El schema fiscal ya existe en 26-fiscal-catalogs.sql +-- Aqui solo agregamos las tablas especificas de CFDI +-- ===================== + +-- ===================== +-- TIPOS ENUMERADOS CFDI +-- ===================== + +-- Tipo de comprobante CFDI +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'cfdi_voucher_type') THEN + CREATE TYPE fiscal.cfdi_voucher_type AS ENUM ( + 'I', -- Ingreso + 'E', -- Egreso + 'T', -- Traslado + 'N', -- Nomina + 'P' -- Pago + ); + END IF; +END $$; + +-- Estado del certificado CSD +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'csd_certificate_status') THEN + CREATE TYPE fiscal.csd_certificate_status AS ENUM ( + 'active', -- Activo y vigente + 'expired', -- Expirado + 'revoked', -- Revocado por el SAT + 'pending', -- Pendiente de activacion + 'inactive' -- Desactivado manualmente + ); + END IF; +END $$; + +-- Estado del CFDI +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'cfdi_status') THEN + CREATE TYPE fiscal.cfdi_status AS ENUM ( + 'draft', -- Borrador, no timbrado + 'pending', -- Pendiente de timbrado + 'stamping', -- En proceso de timbrado + 'stamped', -- Timbrado exitosamente + 'delivered', -- Entregado al receptor + 'cancel_requested', -- Solicitud de cancelacion + 'cancelled', -- Cancelado + 'error' -- Error en timbrado + ); + END IF; +END $$; + +-- Tipo de relacionCFDI +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'cfdi_relation_type') THEN + CREATE TYPE fiscal.cfdi_relation_type AS ENUM ( + '01', -- Nota de credito de documentos relacionados + '02', -- Nota de debito de documentos relacionados + '03', -- Devolucion de mercancia + '04', -- Sustitucion de CFDI previos + '05', -- Traslados de mercancias facturadas + '06', -- Factura generada por traslados previos + '07', -- CFDI por aplicacion de anticipo + '08', -- Factura generada por pagos en parcialidades + '09' -- Factura generada por pagos diferidos + ); + END IF; +END $$; + +-- ===================== +-- TABLA: cfdi_certificates +-- Certificados de Sello Digital (CSD) del SAT +-- ===================== +CREATE TABLE IF NOT EXISTS fiscal.cfdi_certificates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + + -- Identificacion del certificado + certificate_number VARCHAR(20) NOT NULL, -- Numero de certificado SAT (20 digitos) + serial_number VARCHAR(50), -- Numero de serie del certificado + + -- RFC asociado + rfc VARCHAR(13) NOT NULL, -- RFC del contribuyente + + -- Archivos del certificado (almacenados encriptados) + certificate_pem TEXT NOT NULL, -- Certificado .cer en formato PEM + private_key_pem_encrypted TEXT NOT NULL, -- Llave privada .key encriptada + + -- Metadatos del certificado + issued_at TIMESTAMPTZ NOT NULL, -- Fecha de emision + expires_at TIMESTAMPTZ NOT NULL, -- Fecha de expiracion + + -- Estado y configuracion + status fiscal.csd_certificate_status NOT NULL DEFAULT 'pending', + is_default BOOLEAN DEFAULT FALSE, -- Certificado por defecto para este tenant + + -- Informacion adicional + issuer_name VARCHAR(500), -- Nombre del emisor (SAT) + subject_name VARCHAR(500), -- Nombre del sujeto (contribuyente) + + -- Notas + description VARCHAR(255), + notes TEXT, + + -- Auditoria + 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, + + -- Solo un certificado activo por defecto por tenant + UNIQUE(tenant_id, certificate_number), + CONSTRAINT chk_only_one_default EXCLUDE USING btree (tenant_id WITH =) + WHERE (is_default = TRUE AND deleted_at IS NULL AND status = 'active') +); + +COMMENT ON TABLE fiscal.cfdi_certificates IS 'Certificados de Sello Digital (CSD) para timbrado CFDI'; +COMMENT ON COLUMN fiscal.cfdi_certificates.certificate_number IS 'Numero de certificado emitido por el SAT (20 digitos)'; +COMMENT ON COLUMN fiscal.cfdi_certificates.certificate_pem IS 'Certificado .cer convertido a formato PEM'; +COMMENT ON COLUMN fiscal.cfdi_certificates.private_key_pem_encrypted IS 'Llave privada .key encriptada con AES-256'; +COMMENT ON COLUMN fiscal.cfdi_certificates.is_default IS 'Indica si es el certificado predeterminado para facturacion'; + +-- ===================== +-- TABLA: cfdi_pac_configurations +-- Configuracion de Proveedores Autorizados de Certificacion (PAC) +-- ===================== +CREATE TABLE IF NOT EXISTS fiscal.cfdi_pac_configurations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + + -- Identificacion del PAC + pac_code VARCHAR(20) NOT NULL, -- Codigo del PAC (finkok, sat, etc.) + pac_name VARCHAR(100) NOT NULL, -- Nombre comercial del PAC + + -- Credenciales (encriptadas) + username VARCHAR(255), -- Usuario del PAC + password_encrypted TEXT, -- Contrasena encriptada + api_key_encrypted TEXT, -- API Key encriptada (si aplica) + + -- Endpoints + production_url VARCHAR(500), -- URL produccion + sandbox_url VARCHAR(500), -- URL sandbox/pruebas + + -- Configuracion + environment VARCHAR(20) DEFAULT 'sandbox', -- sandbox, production + is_active BOOLEAN DEFAULT TRUE, + is_default BOOLEAN DEFAULT FALSE, + + -- Contrato + contract_number VARCHAR(50), + contract_expires_at DATE, + + -- Limites + monthly_stamp_limit INTEGER, -- Limite mensual de timbres + stamps_used_this_month INTEGER DEFAULT 0, + + -- Auditoria + 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, + + UNIQUE(tenant_id, pac_code) +); + +COMMENT ON TABLE fiscal.cfdi_pac_configurations IS 'Configuracion de conexion con Proveedores Autorizados de Certificacion (PAC)'; +COMMENT ON COLUMN fiscal.cfdi_pac_configurations.pac_code IS 'Codigo identificador del PAC: finkok, sat, facturapi, etc.'; +COMMENT ON COLUMN fiscal.cfdi_pac_configurations.environment IS 'Ambiente: sandbox para pruebas, production para produccion'; + +-- ===================== +-- TABLA: cfdi_invoices +-- Extension de facturas con datos CFDI completos +-- ===================== +CREATE TABLE IF NOT EXISTS fiscal.cfdi_invoices ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + + -- Relacion con factura operacional + invoice_id UUID NOT NULL REFERENCES billing.invoices(id) ON DELETE CASCADE, + + -- Identificadores CFDI + uuid VARCHAR(36), -- UUID del timbre fiscal (folio fiscal) + serie VARCHAR(25), -- Serie del comprobante + folio VARCHAR(40), -- Folio del comprobante + + -- Tipo de comprobante + voucher_type fiscal.cfdi_voucher_type NOT NULL DEFAULT 'I', + + -- Certificado usado + certificate_id UUID REFERENCES fiscal.cfdi_certificates(id), + certificate_number VARCHAR(20), -- Numero de certificado usado + + -- Estado del CFDI + status fiscal.cfdi_status NOT NULL DEFAULT 'draft', + + -- Datos del emisor + issuer_rfc VARCHAR(13) NOT NULL, + issuer_name VARCHAR(300) NOT NULL, + issuer_fiscal_regime VARCHAR(10) NOT NULL, -- Regimen fiscal del emisor + + -- Datos del receptor + receiver_rfc VARCHAR(13) NOT NULL, + receiver_name VARCHAR(300) NOT NULL, + receiver_fiscal_regime VARCHAR(10), -- Regimen fiscal del receptor (CFDI 4.0) + receiver_tax_residence VARCHAR(3), -- Residencia fiscal extranjero + receiver_tax_id VARCHAR(40), -- NumRegIdTrib para extranjeros + cfdi_use VARCHAR(10) NOT NULL, -- Uso del CFDI (G01, G02, etc.) + receiver_zip_code VARCHAR(5), -- Codigo postal receptor (CFDI 4.0) + + -- Lugar de expedicion + expedition_place VARCHAR(5) NOT NULL, -- Codigo postal lugar expedicion + + -- Metodo y forma de pago + payment_method VARCHAR(10), -- PUE, PPD + payment_form VARCHAR(10), -- 01, 02, 03, etc. + payment_conditions VARCHAR(1000), + + -- Moneda y tipo de cambio + currency VARCHAR(3) NOT NULL DEFAULT 'MXN', + exchange_rate DECIMAL(10, 6) DEFAULT 1, + + -- Exportacion + exportation VARCHAR(2) DEFAULT '01', -- 01=No aplica, 02=Definitiva, etc. + + -- Totales + subtotal DECIMAL(18, 6) NOT NULL DEFAULT 0, + discount DECIMAL(18, 6) DEFAULT 0, + total DECIMAL(18, 6) NOT NULL DEFAULT 0, + + -- Impuestos trasladados (resumen) + total_transferred_taxes DECIMAL(18, 6) DEFAULT 0, + -- Impuestos retenidos (resumen) + total_withheld_taxes DECIMAL(18, 6) DEFAULT 0, + + -- CFDI relacionados + related_cfdi_type fiscal.cfdi_relation_type, + + -- Confirmacion SAT (para montos grandes) + confirmation_code VARCHAR(17), + + -- Informacion global (para publico en general) + global_info_periodicity VARCHAR(10), -- 01=Diario, 02=Semanal, etc. + global_info_months VARCHAR(10), -- 01-12 + global_info_year VARCHAR(4), + + -- XML y timbrado + xml_original TEXT, -- XML antes de timbrar + xml_stamped TEXT, -- XML timbrado + stamp_date TIMESTAMPTZ, -- Fecha de timbrado + stamp_sat_seal TEXT, -- Sello del SAT + stamp_cfdi_seal TEXT, -- Sello del CFDI + stamp_original_chain TEXT, -- Cadena original del timbre + sat_certificate_number VARCHAR(20), -- Numero certificado SAT + + -- PDF + pdf_url VARCHAR(500), + pdf_generated_at TIMESTAMPTZ, + + -- Cancelacion + cancellation_status VARCHAR(50), + cancellation_date TIMESTAMPTZ, + cancellation_ack_xml TEXT, + + -- Validacion SAT + last_sat_validation TIMESTAMPTZ, + sat_validation_status VARCHAR(50), + sat_validation_response JSONB, + + -- Errores + last_error TEXT, + error_details JSONB, + + -- Auditoria + 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, + + UNIQUE(tenant_id, uuid), + UNIQUE(invoice_id) +); + +COMMENT ON TABLE fiscal.cfdi_invoices IS 'Extension de facturas con datos completos de CFDI 4.0'; +COMMENT ON COLUMN fiscal.cfdi_invoices.uuid IS 'UUID del timbre fiscal digital (folio fiscal unico del SAT)'; +COMMENT ON COLUMN fiscal.cfdi_invoices.voucher_type IS 'Tipo de comprobante: I=Ingreso, E=Egreso, T=Traslado, N=Nomina, P=Pago'; +COMMENT ON COLUMN fiscal.cfdi_invoices.cfdi_use IS 'Uso del CFDI segun catalogo SAT c_UsoCFDI'; +COMMENT ON COLUMN fiscal.cfdi_invoices.payment_method IS 'Metodo de pago: PUE=Pago en Una Exhibicion, PPD=Pago en Parcialidades o Diferido'; +COMMENT ON COLUMN fiscal.cfdi_invoices.exportation IS 'Tipo de exportacion: 01=No aplica, 02=Definitiva, 03=Temporal, 04=Definitiva con clave'; + +-- ===================== +-- TABLA: cfdi_invoice_items +-- Conceptos/lineas del CFDI con datos fiscales completos +-- ===================== +CREATE TABLE IF NOT EXISTS fiscal.cfdi_invoice_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + cfdi_invoice_id UUID NOT NULL REFERENCES fiscal.cfdi_invoices(id) ON DELETE CASCADE, + + -- Referencia a linea de factura original + invoice_item_id UUID, -- FK a billing.invoice_items si existe + + -- Datos del concepto + line_number INTEGER NOT NULL DEFAULT 1, + + -- Claves SAT + product_service_key VARCHAR(10) NOT NULL, -- Clave producto/servicio SAT + product_service_name VARCHAR(1000), -- Descripcion del catalogo SAT + unit_key VARCHAR(10) NOT NULL, -- Clave unidad SAT + unit_name VARCHAR(50), -- Nombre unidad + + -- Identificacion del producto + sku VARCHAR(100), -- NoIdentificacion + description VARCHAR(1000) NOT NULL, -- Descripcion + + -- Cantidades y montos + quantity DECIMAL(18, 6) NOT NULL, + unit_price DECIMAL(18, 6) NOT NULL, + subtotal DECIMAL(18, 6) NOT NULL, -- Importe = quantity * unit_price + discount DECIMAL(18, 6) DEFAULT 0, + + -- Objeto de impuesto + tax_object VARCHAR(2) NOT NULL DEFAULT '02', -- 01=No objeto, 02=Si objeto, 03=Si objeto no desglosado + + -- Cuenta predial (para inmuebles) + property_tax_account VARCHAR(150), + + -- Informacion aduanera + customs_info JSONB, -- Array de {NumeroPedimento, FechaPedimento, Aduana} + + -- Parte (para componentes) + parts JSONB, -- Array de partes si aplica + + -- Auditoria + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +COMMENT ON TABLE fiscal.cfdi_invoice_items IS 'Conceptos del CFDI con claves SAT y desglose fiscal'; +COMMENT ON COLUMN fiscal.cfdi_invoice_items.product_service_key IS 'Clave de producto/servicio del catalogo SAT c_ClaveProdServ'; +COMMENT ON COLUMN fiscal.cfdi_invoice_items.unit_key IS 'Clave de unidad del catalogo SAT c_ClaveUnidad'; +COMMENT ON COLUMN fiscal.cfdi_invoice_items.tax_object IS 'ObjetoImp: 01=No objeto de impuesto, 02=Si objeto, 03=Si objeto no desglosado'; + +-- ===================== +-- TABLA: cfdi_invoice_item_taxes +-- Impuestos por concepto del CFDI +-- ===================== +CREATE TABLE IF NOT EXISTS fiscal.cfdi_invoice_item_taxes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + cfdi_invoice_item_id UUID NOT NULL REFERENCES fiscal.cfdi_invoice_items(id) ON DELETE CASCADE, + + -- Tipo de impuesto + tax_type VARCHAR(20) NOT NULL, -- transferred (traslado) o withheld (retencion) + + -- Impuesto + tax_code VARCHAR(10) NOT NULL, -- 001=ISR, 002=IVA, 003=IEPS + tax_name VARCHAR(50), + + -- Factor + factor_type VARCHAR(10) NOT NULL, -- Tasa, Cuota, Exento + rate DECIMAL(10, 6), -- Tasa o cuota + + -- Montos + base_amount DECIMAL(18, 6) NOT NULL, -- Base del impuesto + tax_amount DECIMAL(18, 6) NOT NULL, -- Importe del impuesto + + -- Auditoria + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +COMMENT ON TABLE fiscal.cfdi_invoice_item_taxes IS 'Impuestos trasladados y retenidos por concepto del CFDI'; +COMMENT ON COLUMN fiscal.cfdi_invoice_item_taxes.tax_type IS 'transferred=Impuesto trasladado, withheld=Impuesto retenido'; +COMMENT ON COLUMN fiscal.cfdi_invoice_item_taxes.tax_code IS 'Codigo SAT: 001=ISR, 002=IVA, 003=IEPS'; +COMMENT ON COLUMN fiscal.cfdi_invoice_item_taxes.factor_type IS 'Tipo de factor: Tasa, Cuota, o Exento'; + +-- ===================== +-- TABLA: cfdi_related_documents +-- CFDI relacionados +-- ===================== +CREATE TABLE IF NOT EXISTS fiscal.cfdi_related_documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + cfdi_invoice_id UUID NOT NULL REFERENCES fiscal.cfdi_invoices(id) ON DELETE CASCADE, + + -- UUID del CFDI relacionado + related_uuid VARCHAR(36) NOT NULL, + + -- Tipo de relacion + relation_type fiscal.cfdi_relation_type NOT NULL, + + -- Referencia interna (si existe en nuestro sistema) + related_cfdi_invoice_id UUID REFERENCES fiscal.cfdi_invoices(id), + + -- Auditoria + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +COMMENT ON TABLE fiscal.cfdi_related_documents IS 'Relaciones entre CFDIs (notas de credito, sustituciones, etc.)'; +COMMENT ON COLUMN fiscal.cfdi_related_documents.relation_type IS 'Tipo de relacion segun catalogo SAT c_TipoRelacion'; + +-- ===================== +-- INDICES CFDI CORE +-- ===================== + +-- cfdi_certificates +CREATE INDEX IF NOT EXISTS idx_cfdi_certificates_tenant ON fiscal.cfdi_certificates(tenant_id); +CREATE INDEX IF NOT EXISTS idx_cfdi_certificates_rfc ON fiscal.cfdi_certificates(rfc); +CREATE INDEX IF NOT EXISTS idx_cfdi_certificates_status ON fiscal.cfdi_certificates(status); +CREATE INDEX IF NOT EXISTS idx_cfdi_certificates_expires ON fiscal.cfdi_certificates(expires_at); +CREATE INDEX IF NOT EXISTS idx_cfdi_certificates_default ON fiscal.cfdi_certificates(tenant_id, is_default) + WHERE is_default = TRUE AND deleted_at IS NULL; + +-- cfdi_pac_configurations +CREATE INDEX IF NOT EXISTS idx_cfdi_pac_tenant ON fiscal.cfdi_pac_configurations(tenant_id); +CREATE INDEX IF NOT EXISTS idx_cfdi_pac_code ON fiscal.cfdi_pac_configurations(pac_code); +CREATE INDEX IF NOT EXISTS idx_cfdi_pac_active ON fiscal.cfdi_pac_configurations(tenant_id, is_active) + WHERE is_active = TRUE; + +-- cfdi_invoices +CREATE INDEX IF NOT EXISTS idx_cfdi_invoices_tenant ON fiscal.cfdi_invoices(tenant_id); +CREATE INDEX IF NOT EXISTS idx_cfdi_invoices_invoice ON fiscal.cfdi_invoices(invoice_id); +CREATE INDEX IF NOT EXISTS idx_cfdi_invoices_uuid ON fiscal.cfdi_invoices(uuid); +CREATE INDEX IF NOT EXISTS idx_cfdi_invoices_serie_folio ON fiscal.cfdi_invoices(serie, folio); +CREATE INDEX IF NOT EXISTS idx_cfdi_invoices_status ON fiscal.cfdi_invoices(status); +CREATE INDEX IF NOT EXISTS idx_cfdi_invoices_issuer ON fiscal.cfdi_invoices(issuer_rfc); +CREATE INDEX IF NOT EXISTS idx_cfdi_invoices_receiver ON fiscal.cfdi_invoices(receiver_rfc); +CREATE INDEX IF NOT EXISTS idx_cfdi_invoices_stamp_date ON fiscal.cfdi_invoices(stamp_date); +CREATE INDEX IF NOT EXISTS idx_cfdi_invoices_voucher_type ON fiscal.cfdi_invoices(voucher_type); +CREATE INDEX IF NOT EXISTS idx_cfdi_invoices_pending ON fiscal.cfdi_invoices(tenant_id, status) + WHERE status IN ('draft', 'pending', 'stamping'); + +-- cfdi_invoice_items +CREATE INDEX IF NOT EXISTS idx_cfdi_items_cfdi ON fiscal.cfdi_invoice_items(cfdi_invoice_id); +CREATE INDEX IF NOT EXISTS idx_cfdi_items_product_key ON fiscal.cfdi_invoice_items(product_service_key); +CREATE INDEX IF NOT EXISTS idx_cfdi_items_line ON fiscal.cfdi_invoice_items(cfdi_invoice_id, line_number); + +-- cfdi_invoice_item_taxes +CREATE INDEX IF NOT EXISTS idx_cfdi_item_taxes_item ON fiscal.cfdi_invoice_item_taxes(cfdi_invoice_item_id); +CREATE INDEX IF NOT EXISTS idx_cfdi_item_taxes_code ON fiscal.cfdi_invoice_item_taxes(tax_code); + +-- cfdi_related_documents +CREATE INDEX IF NOT EXISTS idx_cfdi_related_cfdi ON fiscal.cfdi_related_documents(cfdi_invoice_id); +CREATE INDEX IF NOT EXISTS idx_cfdi_related_uuid ON fiscal.cfdi_related_documents(related_uuid); + +-- ===================== +-- FIN DEL ARCHIVO +-- ===================== diff --git a/ddl/28-cfdi-operations.sql b/ddl/28-cfdi-operations.sql new file mode 100644 index 0000000..496ded1 --- /dev/null +++ b/ddl/28-cfdi-operations.sql @@ -0,0 +1,442 @@ +-- ============================================================= +-- ARCHIVO: 28-cfdi-operations.sql +-- DESCRIPCION: Modulo CFDI - Operaciones: cancelaciones, logs, +-- complementos de pago +-- VERSION: 1.0.0 +-- PROYECTO: ERP-Core V2 +-- FECHA: 2026-02-03 +-- DEPENDE DE: 27-cfdi-core.sql +-- ============================================================= + +-- ===================== +-- TIPOS ENUMERADOS OPERACIONES +-- ===================== + +-- Motivo de cancelacion CFDI +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'cfdi_cancellation_reason') THEN + CREATE TYPE fiscal.cfdi_cancellation_reason AS ENUM ( + '01', -- Comprobante emitido con errores con relacion + '02', -- Comprobante emitido con errores sin relacion + '03', -- No se llevo a cabo la operacion + '04' -- Operacion nominativa relacionada en factura global + ); + END IF; +END $$; + +-- Estado de solicitud de cancelacion +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'cancellation_request_status') THEN + CREATE TYPE fiscal.cancellation_request_status AS ENUM ( + 'pending', -- Pendiente de enviar + 'submitted', -- Enviada al SAT + 'accepted', -- Aceptada por el receptor + 'rejected', -- Rechazada por el receptor + 'in_process', -- En proceso (plazo de aceptacion) + 'expired', -- Plazo expirado (cancelada automaticamente) + 'cancelled', -- Cancelacion exitosa + 'error' -- Error en el proceso + ); + END IF; +END $$; + +-- Tipo de operacion en log +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'cfdi_operation_type') THEN + CREATE TYPE fiscal.cfdi_operation_type AS ENUM ( + 'create', -- Creacion de CFDI + 'stamp', -- Timbrado + 'stamp_retry', -- Reintento de timbrado + 'cancel_request', -- Solicitud de cancelacion + 'cancel_accept', -- Aceptacion de cancelacion + 'cancel_reject', -- Rechazo de cancelacion + 'cancel_complete', -- Cancelacion completada + 'validate', -- Validacion en SAT + 'download', -- Descarga de XML/PDF + 'email', -- Envio por email + 'error' -- Error en operacion + ); + END IF; +END $$; + +-- Estado del complemento de pago +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'payment_complement_status') THEN + CREATE TYPE fiscal.payment_complement_status AS ENUM ( + 'draft', -- Borrador + 'pending', -- Pendiente de timbrar + 'stamped', -- Timbrado exitosamente + 'cancelled', -- Cancelado + 'error' -- Error + ); + END IF; +END $$; + +-- ===================== +-- TABLA: cfdi_cancellation_requests +-- Solicitudes de cancelacion de CFDI +-- ===================== +CREATE TABLE IF NOT EXISTS fiscal.cfdi_cancellation_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + + -- CFDI a cancelar + cfdi_invoice_id UUID NOT NULL REFERENCES fiscal.cfdi_invoices(id) ON DELETE CASCADE, + cfdi_uuid VARCHAR(36) NOT NULL, -- UUID del CFDI a cancelar + + -- Motivo de cancelacion + cancellation_reason fiscal.cfdi_cancellation_reason NOT NULL, + + -- CFDI sustituto (para motivo 01) + substitute_uuid VARCHAR(36), -- UUID del CFDI que sustituye + substitute_cfdi_id UUID REFERENCES fiscal.cfdi_invoices(id), + + -- Estado de la solicitud + status fiscal.cancellation_request_status NOT NULL DEFAULT 'pending', + + -- Fechas del proceso + requested_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + submitted_at TIMESTAMPTZ, -- Fecha de envio al SAT + response_at TIMESTAMPTZ, -- Fecha de respuesta + expires_at TIMESTAMPTZ, -- Fecha limite para aceptacion (72h) + completed_at TIMESTAMPTZ, -- Fecha de cancelacion efectiva + + -- Respuesta del SAT + sat_response_code VARCHAR(10), + sat_response_message TEXT, + sat_ack_xml TEXT, -- Acuse de cancelacion + + -- Respuesta del receptor (si aplica) + receiver_response VARCHAR(20), -- accepted, rejected + receiver_response_at TIMESTAMPTZ, + receiver_response_reason TEXT, + + -- Notas + reason_notes TEXT, -- Notas adicionales del motivo + internal_notes TEXT, + + -- Errores + last_error TEXT, + error_details JSONB, + retry_count INTEGER DEFAULT 0, + + -- Auditoria + 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(cfdi_invoice_id) +); + +COMMENT ON TABLE fiscal.cfdi_cancellation_requests IS 'Solicitudes de cancelacion de CFDI segun proceso SAT 2022+'; +COMMENT ON COLUMN fiscal.cfdi_cancellation_requests.cancellation_reason IS 'Motivo: 01=Con relacion, 02=Sin relacion, 03=No se realizo operacion, 04=Nominativa global'; +COMMENT ON COLUMN fiscal.cfdi_cancellation_requests.expires_at IS 'Fecha limite para que el receptor acepte/rechace (72 horas habiles)'; + +-- ===================== +-- TABLA: cfdi_operation_logs +-- Audit trail de todas las operaciones CFDI +-- ===================== +CREATE TABLE IF NOT EXISTS fiscal.cfdi_operation_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + + -- Referencia al CFDI + cfdi_invoice_id UUID REFERENCES fiscal.cfdi_invoices(id) ON DELETE SET NULL, + cfdi_uuid VARCHAR(36), -- Guardamos UUID por si se elimina el registro + + -- Tipo de operacion + operation_type fiscal.cfdi_operation_type NOT NULL, + + -- Estado antes y despues + status_before VARCHAR(50), + status_after VARCHAR(50), + + -- Resultado + success BOOLEAN NOT NULL DEFAULT FALSE, + + -- Detalles de la operacion + request_payload JSONB, -- Datos enviados + response_payload JSONB, -- Respuesta recibida + + -- Error (si aplica) + error_code VARCHAR(50), + error_message TEXT, + error_details JSONB, + + -- PAC utilizado + pac_code VARCHAR(20), + pac_transaction_id VARCHAR(100), + + -- Tiempo de respuesta + duration_ms INTEGER, -- Duracion en milisegundos + + -- IP y user agent (para auditorias) + ip_address INET, + user_agent VARCHAR(500), + + -- Auditoria + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + created_by UUID REFERENCES auth.users(id) +); + +COMMENT ON TABLE fiscal.cfdi_operation_logs IS 'Log de todas las operaciones realizadas sobre CFDIs'; +COMMENT ON COLUMN fiscal.cfdi_operation_logs.operation_type IS 'Tipo de operacion: stamp, cancel_request, validate, etc.'; +COMMENT ON COLUMN fiscal.cfdi_operation_logs.duration_ms IS 'Tiempo de respuesta del PAC/SAT en milisegundos'; + +-- ===================== +-- TABLA: cfdi_payment_complements +-- Complementos de pago (CFDI tipo P - REP) +-- ===================== +CREATE TABLE IF NOT EXISTS fiscal.cfdi_payment_complements ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + + -- Pago asociado + payment_id UUID REFERENCES billing.payments(id) ON DELETE SET NULL, + + -- Identificadores CFDI + uuid VARCHAR(36), -- UUID del complemento de pago + serie VARCHAR(25), + folio VARCHAR(40), + + -- Estado + status fiscal.payment_complement_status NOT NULL DEFAULT 'draft', + + -- Certificado usado + certificate_id UUID REFERENCES fiscal.cfdi_certificates(id), + certificate_number VARCHAR(20), + + -- Datos del emisor + issuer_rfc VARCHAR(13) NOT NULL, + issuer_name VARCHAR(300) NOT NULL, + issuer_fiscal_regime VARCHAR(10) NOT NULL, + + -- Datos del receptor + receiver_rfc VARCHAR(13) NOT NULL, + receiver_name VARCHAR(300) NOT NULL, + receiver_fiscal_regime VARCHAR(10), + receiver_zip_code VARCHAR(5), + + -- Lugar de expedicion + expedition_place VARCHAR(5) NOT NULL, + + -- Datos del pago + payment_date DATE NOT NULL, + payment_form VARCHAR(10) NOT NULL, -- Forma de pago SAT + currency VARCHAR(3) NOT NULL DEFAULT 'MXN', + exchange_rate DECIMAL(10, 6) DEFAULT 1, + total_amount DECIMAL(18, 6) NOT NULL, + + -- Datos bancarios (si aplica) + payer_bank_rfc VARCHAR(13), + payer_bank_name VARCHAR(300), + payer_bank_account VARCHAR(50), + payee_bank_rfc VARCHAR(13), + payee_bank_account VARCHAR(50), + + -- Cadena de pago (para transferencias) + payment_chain TEXT, + payment_certificate TEXT, + payment_stamp TEXT, + + -- Operacion (efectivo, cheque, etc.) + operation_number VARCHAR(100), + + -- XML y timbrado + xml_original TEXT, + xml_stamped TEXT, + stamp_date TIMESTAMPTZ, + stamp_sat_seal TEXT, + stamp_cfdi_seal TEXT, + stamp_original_chain TEXT, + sat_certificate_number VARCHAR(20), + + -- PDF + pdf_url VARCHAR(500), + pdf_generated_at TIMESTAMPTZ, + + -- Cancelacion + cancellation_request_id UUID REFERENCES fiscal.cfdi_cancellation_requests(id), + cancellation_date TIMESTAMPTZ, + + -- Errores + last_error TEXT, + error_details JSONB, + + -- Auditoria + 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, + + UNIQUE(tenant_id, uuid) +); + +COMMENT ON TABLE fiscal.cfdi_payment_complements IS 'Complementos de pago (CFDI tipo P - Recibo Electronico de Pago)'; +COMMENT ON COLUMN fiscal.cfdi_payment_complements.uuid IS 'UUID del timbre fiscal del complemento de pago'; +COMMENT ON COLUMN fiscal.cfdi_payment_complements.payment_form IS 'Forma de pago segun catalogo SAT c_FormaPago'; + +-- ===================== +-- TABLA: cfdi_payment_complement_documents +-- Documentos relacionados en el complemento de pago +-- ===================== +CREATE TABLE IF NOT EXISTS fiscal.cfdi_payment_complement_documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + payment_complement_id UUID NOT NULL REFERENCES fiscal.cfdi_payment_complements(id) ON DELETE CASCADE, + + -- CFDI del documento relacionado + related_uuid VARCHAR(36) NOT NULL, -- IdDocumento - UUID de la factura pagada + related_cfdi_id UUID REFERENCES fiscal.cfdi_invoices(id), + + -- Serie y folio del documento + serie VARCHAR(25), + folio VARCHAR(40), + + -- Moneda del documento + document_currency VARCHAR(3) NOT NULL DEFAULT 'MXN', + equivalence DECIMAL(18, 6) DEFAULT 1, -- Equivalencia DR (tipo cambio) + + -- Numero de parcialidad + installment_number INTEGER NOT NULL DEFAULT 1, + + -- Saldos + previous_balance DECIMAL(18, 6) NOT NULL, -- ImpSaldoAnt + amount_paid DECIMAL(18, 6) NOT NULL, -- ImpPagado + remaining_balance DECIMAL(18, 6) NOT NULL, -- ImpSaldoInsoluto + + -- Objeto de impuesto + tax_object VARCHAR(2) DEFAULT '02', -- ObjetoImpDR + + -- Auditoria + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +COMMENT ON TABLE fiscal.cfdi_payment_complement_documents IS 'Documentos (facturas) incluidos en el complemento de pago'; +COMMENT ON COLUMN fiscal.cfdi_payment_complement_documents.related_uuid IS 'UUID de la factura que se esta pagando'; +COMMENT ON COLUMN fiscal.cfdi_payment_complement_documents.installment_number IS 'Numero de parcialidad (1, 2, 3...)'; +COMMENT ON COLUMN fiscal.cfdi_payment_complement_documents.previous_balance IS 'Saldo anterior antes de este pago'; +COMMENT ON COLUMN fiscal.cfdi_payment_complement_documents.remaining_balance IS 'Saldo pendiente despues de este pago'; + +-- ===================== +-- TABLA: cfdi_payment_complement_document_taxes +-- Impuestos por documento en complemento de pago +-- ===================== +CREATE TABLE IF NOT EXISTS fiscal.cfdi_payment_complement_document_taxes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + payment_complement_document_id UUID NOT NULL REFERENCES fiscal.cfdi_payment_complement_documents(id) ON DELETE CASCADE, + + -- Tipo de impuesto + tax_type VARCHAR(20) NOT NULL, -- transferred o withheld + + -- Impuesto + tax_code VARCHAR(10) NOT NULL, -- 001=ISR, 002=IVA, 003=IEPS + + -- Factor + factor_type VARCHAR(10) NOT NULL, -- Tasa, Cuota, Exento + rate DECIMAL(10, 6), + + -- Montos + base_amount DECIMAL(18, 6) NOT NULL, + tax_amount DECIMAL(18, 6) NOT NULL, + + -- Auditoria + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +COMMENT ON TABLE fiscal.cfdi_payment_complement_document_taxes IS 'Impuestos por documento relacionado en el complemento de pago'; + +-- ===================== +-- TABLA: cfdi_stamp_queue +-- Cola de timbrado para procesamiento asincrono +-- ===================== +CREATE TABLE IF NOT EXISTS fiscal.cfdi_stamp_queue ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + + -- Tipo de documento a timbrar + document_type VARCHAR(20) NOT NULL, -- invoice, payment_complement + document_id UUID NOT NULL, -- ID del documento + + -- Prioridad + priority INTEGER DEFAULT 5, -- 1=urgente, 5=normal, 10=baja + + -- Estado de la cola + queue_status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending, processing, completed, failed + + -- Intentos + attempts INTEGER DEFAULT 0, + max_attempts INTEGER DEFAULT 3, + next_retry_at TIMESTAMPTZ, + + -- Resultado + completed_at TIMESTAMPTZ, + result_cfdi_uuid VARCHAR(36), + result_error TEXT, + + -- Auditoria + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + created_by UUID REFERENCES auth.users(id), + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +COMMENT ON TABLE fiscal.cfdi_stamp_queue IS 'Cola de documentos pendientes de timbrar para procesamiento asincrono'; + +-- ===================== +-- INDICES OPERACIONES +-- ===================== + +-- cfdi_cancellation_requests +CREATE INDEX IF NOT EXISTS idx_cfdi_cancel_tenant ON fiscal.cfdi_cancellation_requests(tenant_id); +CREATE INDEX IF NOT EXISTS idx_cfdi_cancel_cfdi ON fiscal.cfdi_cancellation_requests(cfdi_invoice_id); +CREATE INDEX IF NOT EXISTS idx_cfdi_cancel_uuid ON fiscal.cfdi_cancellation_requests(cfdi_uuid); +CREATE INDEX IF NOT EXISTS idx_cfdi_cancel_status ON fiscal.cfdi_cancellation_requests(status); +CREATE INDEX IF NOT EXISTS idx_cfdi_cancel_expires ON fiscal.cfdi_cancellation_requests(expires_at) + WHERE status IN ('submitted', 'in_process'); +CREATE INDEX IF NOT EXISTS idx_cfdi_cancel_pending ON fiscal.cfdi_cancellation_requests(tenant_id, status) + WHERE status IN ('pending', 'submitted', 'in_process'); + +-- cfdi_operation_logs +CREATE INDEX IF NOT EXISTS idx_cfdi_logs_tenant ON fiscal.cfdi_operation_logs(tenant_id); +CREATE INDEX IF NOT EXISTS idx_cfdi_logs_cfdi ON fiscal.cfdi_operation_logs(cfdi_invoice_id); +CREATE INDEX IF NOT EXISTS idx_cfdi_logs_uuid ON fiscal.cfdi_operation_logs(cfdi_uuid); +CREATE INDEX IF NOT EXISTS idx_cfdi_logs_type ON fiscal.cfdi_operation_logs(operation_type); +CREATE INDEX IF NOT EXISTS idx_cfdi_logs_date ON fiscal.cfdi_operation_logs(created_at); +CREATE INDEX IF NOT EXISTS idx_cfdi_logs_success ON fiscal.cfdi_operation_logs(success); +CREATE INDEX IF NOT EXISTS idx_cfdi_logs_errors ON fiscal.cfdi_operation_logs(tenant_id, operation_type, created_at) + WHERE success = FALSE; + +-- cfdi_payment_complements +CREATE INDEX IF NOT EXISTS idx_cfdi_pcomp_tenant ON fiscal.cfdi_payment_complements(tenant_id); +CREATE INDEX IF NOT EXISTS idx_cfdi_pcomp_payment ON fiscal.cfdi_payment_complements(payment_id); +CREATE INDEX IF NOT EXISTS idx_cfdi_pcomp_uuid ON fiscal.cfdi_payment_complements(uuid); +CREATE INDEX IF NOT EXISTS idx_cfdi_pcomp_status ON fiscal.cfdi_payment_complements(status); +CREATE INDEX IF NOT EXISTS idx_cfdi_pcomp_issuer ON fiscal.cfdi_payment_complements(issuer_rfc); +CREATE INDEX IF NOT EXISTS idx_cfdi_pcomp_receiver ON fiscal.cfdi_payment_complements(receiver_rfc); +CREATE INDEX IF NOT EXISTS idx_cfdi_pcomp_date ON fiscal.cfdi_payment_complements(payment_date); + +-- cfdi_payment_complement_documents +CREATE INDEX IF NOT EXISTS idx_cfdi_pcomp_docs_comp ON fiscal.cfdi_payment_complement_documents(payment_complement_id); +CREATE INDEX IF NOT EXISTS idx_cfdi_pcomp_docs_uuid ON fiscal.cfdi_payment_complement_documents(related_uuid); +CREATE INDEX IF NOT EXISTS idx_cfdi_pcomp_docs_cfdi ON fiscal.cfdi_payment_complement_documents(related_cfdi_id); + +-- cfdi_payment_complement_document_taxes +CREATE INDEX IF NOT EXISTS idx_cfdi_pcomp_dtax_doc ON fiscal.cfdi_payment_complement_document_taxes(payment_complement_document_id); + +-- cfdi_stamp_queue +CREATE INDEX IF NOT EXISTS idx_cfdi_queue_tenant ON fiscal.cfdi_stamp_queue(tenant_id); +CREATE INDEX IF NOT EXISTS idx_cfdi_queue_status ON fiscal.cfdi_stamp_queue(queue_status); +CREATE INDEX IF NOT EXISTS idx_cfdi_queue_priority ON fiscal.cfdi_stamp_queue(priority, created_at) + WHERE queue_status = 'pending'; +CREATE INDEX IF NOT EXISTS idx_cfdi_queue_retry ON fiscal.cfdi_stamp_queue(next_retry_at) + WHERE queue_status = 'failed' AND attempts < max_attempts; +CREATE INDEX IF NOT EXISTS idx_cfdi_queue_doc ON fiscal.cfdi_stamp_queue(document_type, document_id); + +-- ===================== +-- FIN DEL ARCHIVO +-- ===================== diff --git a/ddl/29-cfdi-rls-functions.sql b/ddl/29-cfdi-rls-functions.sql new file mode 100644 index 0000000..8f1863a --- /dev/null +++ b/ddl/29-cfdi-rls-functions.sql @@ -0,0 +1,530 @@ +-- ============================================================= +-- ARCHIVO: 29-cfdi-rls-functions.sql +-- DESCRIPCION: Modulo CFDI - RLS policies, funciones auxiliares, +-- triggers y vistas +-- VERSION: 1.0.0 +-- PROYECTO: ERP-Core V2 +-- FECHA: 2026-02-03 +-- DEPENDE DE: 27-cfdi-core.sql, 28-cfdi-operations.sql +-- ============================================================= + +-- ===================== +-- ROW LEVEL SECURITY (RLS) +-- ===================== + +-- cfdi_certificates +ALTER TABLE fiscal.cfdi_certificates ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_isolation_cfdi_certificates ON fiscal.cfdi_certificates; +CREATE POLICY tenant_isolation_cfdi_certificates ON fiscal.cfdi_certificates + USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); + +-- cfdi_pac_configurations +ALTER TABLE fiscal.cfdi_pac_configurations ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_isolation_cfdi_pac ON fiscal.cfdi_pac_configurations; +CREATE POLICY tenant_isolation_cfdi_pac ON fiscal.cfdi_pac_configurations + USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); + +-- cfdi_invoices +ALTER TABLE fiscal.cfdi_invoices ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_isolation_cfdi_invoices ON fiscal.cfdi_invoices; +CREATE POLICY tenant_isolation_cfdi_invoices ON fiscal.cfdi_invoices + USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); + +-- cfdi_cancellation_requests +ALTER TABLE fiscal.cfdi_cancellation_requests ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_isolation_cfdi_cancellations ON fiscal.cfdi_cancellation_requests; +CREATE POLICY tenant_isolation_cfdi_cancellations ON fiscal.cfdi_cancellation_requests + USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); + +-- cfdi_operation_logs +ALTER TABLE fiscal.cfdi_operation_logs ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_isolation_cfdi_logs ON fiscal.cfdi_operation_logs; +CREATE POLICY tenant_isolation_cfdi_logs ON fiscal.cfdi_operation_logs + USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); + +-- cfdi_payment_complements +ALTER TABLE fiscal.cfdi_payment_complements ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_isolation_cfdi_payment_comp ON fiscal.cfdi_payment_complements; +CREATE POLICY tenant_isolation_cfdi_payment_comp ON fiscal.cfdi_payment_complements + USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); + +-- cfdi_stamp_queue +ALTER TABLE fiscal.cfdi_stamp_queue ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_isolation_cfdi_queue ON fiscal.cfdi_stamp_queue; +CREATE POLICY tenant_isolation_cfdi_queue ON fiscal.cfdi_stamp_queue + USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); + +-- ===================== +-- COMENTARIOS RLS +-- ===================== +COMMENT ON POLICY tenant_isolation_cfdi_certificates ON fiscal.cfdi_certificates IS 'Aislamiento multi-tenant para certificados CSD'; +COMMENT ON POLICY tenant_isolation_cfdi_invoices ON fiscal.cfdi_invoices IS 'Aislamiento multi-tenant para CFDIs'; +COMMENT ON POLICY tenant_isolation_cfdi_cancellations ON fiscal.cfdi_cancellation_requests IS 'Aislamiento multi-tenant para cancelaciones'; +COMMENT ON POLICY tenant_isolation_cfdi_logs ON fiscal.cfdi_operation_logs IS 'Aislamiento multi-tenant para logs de operaciones'; +COMMENT ON POLICY tenant_isolation_cfdi_payment_comp ON fiscal.cfdi_payment_complements IS 'Aislamiento multi-tenant para complementos de pago'; + +-- ===================== +-- FUNCIONES AUXILIARES +-- ===================== + +-- Funcion: Obtener certificado activo por defecto del tenant +CREATE OR REPLACE FUNCTION fiscal.get_default_certificate(p_tenant_id UUID) +RETURNS UUID AS $$ +DECLARE + v_certificate_id UUID; +BEGIN + SELECT id INTO v_certificate_id + FROM fiscal.cfdi_certificates + WHERE tenant_id = p_tenant_id + AND is_default = TRUE + AND status = 'active' + AND deleted_at IS NULL + AND expires_at > CURRENT_TIMESTAMP + LIMIT 1; + + RETURN v_certificate_id; +END; +$$ LANGUAGE plpgsql STABLE; + +COMMENT ON FUNCTION fiscal.get_default_certificate IS 'Obtiene el ID del certificado CSD activo y vigente por defecto del tenant'; + +-- Funcion: Obtener configuracion PAC activa del tenant +CREATE OR REPLACE FUNCTION fiscal.get_active_pac(p_tenant_id UUID) +RETURNS TABLE ( + pac_id UUID, + pac_code VARCHAR, + environment VARCHAR, + api_url VARCHAR +) AS $$ +BEGIN + RETURN QUERY + SELECT + pc.id, + pc.pac_code, + pc.environment, + CASE + WHEN pc.environment = 'production' THEN pc.production_url + ELSE pc.sandbox_url + END as api_url + FROM fiscal.cfdi_pac_configurations pc + WHERE pc.tenant_id = p_tenant_id + AND pc.is_active = TRUE + AND pc.is_default = TRUE + AND pc.deleted_at IS NULL + LIMIT 1; +END; +$$ LANGUAGE plpgsql STABLE; + +COMMENT ON FUNCTION fiscal.get_active_pac IS 'Obtiene la configuracion del PAC activo del tenant'; + +-- Funcion: Validar RFC mexicano +CREATE OR REPLACE FUNCTION fiscal.validate_rfc(p_rfc VARCHAR) +RETURNS BOOLEAN AS $$ +DECLARE + v_pattern_fisica VARCHAR := '^[A-Z&N]{4}[0-9]{6}[A-Z0-9]{3}$'; + v_pattern_moral VARCHAR := '^[A-Z&N]{3}[0-9]{6}[A-Z0-9]{3}$'; + v_rfc VARCHAR; +BEGIN + -- Normalizar RFC (mayusculas, sin espacios) + v_rfc := UPPER(TRIM(p_rfc)); + + -- RFC generico publico en general + IF v_rfc = 'XAXX010101000' THEN + RETURN TRUE; + END IF; + + -- RFC generico extranjero + IF v_rfc = 'XEXX010101000' THEN + RETURN TRUE; + END IF; + + -- Validar patron persona fisica (13 caracteres) + IF LENGTH(v_rfc) = 13 AND v_rfc ~ v_pattern_fisica THEN + RETURN TRUE; + END IF; + + -- Validar patron persona moral (12 caracteres) + IF LENGTH(v_rfc) = 12 AND v_rfc ~ v_pattern_moral THEN + RETURN TRUE; + END IF; + + RETURN FALSE; +END; +$$ LANGUAGE plpgsql IMMUTABLE; + +COMMENT ON FUNCTION fiscal.validate_rfc IS 'Valida el formato de un RFC mexicano (persona fisica o moral)'; + +-- Funcion: Generar folio CFDI +CREATE OR REPLACE FUNCTION fiscal.generate_cfdi_folio( + p_tenant_id UUID, + p_serie VARCHAR DEFAULT NULL +) +RETURNS VARCHAR AS $$ +DECLARE + v_next_folio INTEGER; + v_serie VARCHAR; +BEGIN + -- Serie por defecto si no se proporciona + v_serie := COALESCE(p_serie, 'A'); + + -- Obtener siguiente folio + SELECT COALESCE(MAX(folio::INTEGER), 0) + 1 + INTO v_next_folio + FROM fiscal.cfdi_invoices + WHERE tenant_id = p_tenant_id + AND serie = v_serie + AND folio ~ '^\d+$'; -- Solo folios numericos + + RETURN v_next_folio::VARCHAR; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION fiscal.generate_cfdi_folio IS 'Genera el siguiente folio para una serie de CFDI'; + +-- Funcion: Verificar si CFDI puede ser cancelado +CREATE OR REPLACE FUNCTION fiscal.can_cancel_cfdi(p_cfdi_id UUID) +RETURNS TABLE ( + can_cancel BOOLEAN, + reason VARCHAR, + requires_acceptance BOOLEAN +) AS $$ +DECLARE + v_cfdi RECORD; + v_has_payments BOOLEAN; + v_is_month_old BOOLEAN; +BEGIN + -- Obtener datos del CFDI + SELECT ci.*, bi.total, bi.amount_paid + INTO v_cfdi + FROM fiscal.cfdi_invoices ci + JOIN billing.invoices bi ON ci.invoice_id = bi.id + WHERE ci.id = p_cfdi_id; + + IF NOT FOUND THEN + RETURN QUERY SELECT FALSE, 'CFDI no encontrado'::VARCHAR, FALSE; + RETURN; + END IF; + + -- Verificar estado + IF v_cfdi.status != 'stamped' THEN + RETURN QUERY SELECT FALSE, 'CFDI no esta timbrado'::VARCHAR, FALSE; + RETURN; + END IF; + + -- Verificar si ya tiene solicitud de cancelacion + IF EXISTS ( + SELECT 1 FROM fiscal.cfdi_cancellation_requests + WHERE cfdi_invoice_id = p_cfdi_id + AND status NOT IN ('rejected', 'error') + ) THEN + RETURN QUERY SELECT FALSE, 'Ya existe solicitud de cancelacion'::VARCHAR, FALSE; + RETURN; + END IF; + + -- Verificar si tiene pagos aplicados + v_has_payments := COALESCE(v_cfdi.amount_paid, 0) > 0; + + -- Verificar si tiene mas de un mes + v_is_month_old := v_cfdi.stamp_date < (CURRENT_DATE - INTERVAL '1 month'); + + -- Determinar si requiere aceptacion del receptor + -- Segun reglas SAT 2022+ + IF v_cfdi.total > 5000 OR v_is_month_old OR v_has_payments THEN + RETURN QUERY SELECT TRUE, 'Puede cancelar con aceptacion del receptor'::VARCHAR, TRUE; + ELSE + RETURN QUERY SELECT TRUE, 'Puede cancelar sin aceptacion'::VARCHAR, FALSE; + END IF; +END; +$$ LANGUAGE plpgsql STABLE; + +COMMENT ON FUNCTION fiscal.can_cancel_cfdi IS 'Verifica si un CFDI puede ser cancelado y si requiere aceptacion del receptor'; + +-- Funcion: Calcular saldo pendiente de una factura con pagos parciales +CREATE OR REPLACE FUNCTION fiscal.get_invoice_payment_balance(p_cfdi_uuid VARCHAR) +RETURNS TABLE ( + total_amount DECIMAL, + total_paid DECIMAL, + remaining_balance DECIMAL, + installment_count INTEGER +) AS $$ +BEGIN + RETURN QUERY + SELECT + ci.total, + COALESCE(SUM(pcd.amount_paid), 0) as total_paid, + ci.total - COALESCE(SUM(pcd.amount_paid), 0) as remaining_balance, + COUNT(pcd.id)::INTEGER as installment_count + FROM fiscal.cfdi_invoices ci + LEFT JOIN fiscal.cfdi_payment_complement_documents pcd + ON pcd.related_uuid = ci.uuid + LEFT JOIN fiscal.cfdi_payment_complements pc + ON pc.id = pcd.payment_complement_id + AND pc.status = 'stamped' + WHERE ci.uuid = p_cfdi_uuid + GROUP BY ci.id, ci.total; +END; +$$ LANGUAGE plpgsql STABLE; + +COMMENT ON FUNCTION fiscal.get_invoice_payment_balance IS 'Obtiene el saldo pendiente de una factura considerando complementos de pago'; + +-- Funcion: Obtener resumen de CFDIs por periodo +CREATE OR REPLACE FUNCTION fiscal.get_cfdi_summary( + p_tenant_id UUID, + p_start_date DATE, + p_end_date DATE +) +RETURNS TABLE ( + voucher_type fiscal.cfdi_voucher_type, + status fiscal.cfdi_status, + count BIGINT, + total_amount DECIMAL +) AS $$ +BEGIN + RETURN QUERY + SELECT + ci.voucher_type, + ci.status, + COUNT(*) as count, + SUM(ci.total) as total_amount + FROM fiscal.cfdi_invoices ci + WHERE ci.tenant_id = p_tenant_id + AND ci.created_at::DATE BETWEEN p_start_date AND p_end_date + AND ci.deleted_at IS NULL + GROUP BY ci.voucher_type, ci.status + ORDER BY ci.voucher_type, ci.status; +END; +$$ LANGUAGE plpgsql STABLE; + +COMMENT ON FUNCTION fiscal.get_cfdi_summary IS 'Obtiene resumen de CFDIs agrupados por tipo y estado en un periodo'; + +-- ===================== +-- TRIGGERS +-- ===================== + +-- Trigger: Actualizar updated_at en cfdi_invoices +CREATE OR REPLACE FUNCTION fiscal.update_cfdi_timestamp() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_cfdi_invoices_timestamp ON fiscal.cfdi_invoices; +CREATE TRIGGER trg_cfdi_invoices_timestamp + BEFORE UPDATE ON fiscal.cfdi_invoices + FOR EACH ROW EXECUTE FUNCTION fiscal.update_cfdi_timestamp(); + +DROP TRIGGER IF EXISTS trg_cfdi_certificates_timestamp ON fiscal.cfdi_certificates; +CREATE TRIGGER trg_cfdi_certificates_timestamp + BEFORE UPDATE ON fiscal.cfdi_certificates + FOR EACH ROW EXECUTE FUNCTION fiscal.update_cfdi_timestamp(); + +DROP TRIGGER IF EXISTS trg_cfdi_pac_timestamp ON fiscal.cfdi_pac_configurations; +CREATE TRIGGER trg_cfdi_pac_timestamp + BEFORE UPDATE ON fiscal.cfdi_pac_configurations + FOR EACH ROW EXECUTE FUNCTION fiscal.update_cfdi_timestamp(); + +DROP TRIGGER IF EXISTS trg_cfdi_pcomp_timestamp ON fiscal.cfdi_payment_complements; +CREATE TRIGGER trg_cfdi_pcomp_timestamp + BEFORE UPDATE ON fiscal.cfdi_payment_complements + FOR EACH ROW EXECUTE FUNCTION fiscal.update_cfdi_timestamp(); + +DROP TRIGGER IF EXISTS trg_cfdi_cancel_timestamp ON fiscal.cfdi_cancellation_requests; +CREATE TRIGGER trg_cfdi_cancel_timestamp + BEFORE UPDATE ON fiscal.cfdi_cancellation_requests + FOR EACH ROW EXECUTE FUNCTION fiscal.update_cfdi_timestamp(); + +-- Trigger: Log automatico de cambios de estado +CREATE OR REPLACE FUNCTION fiscal.log_cfdi_status_change() +RETURNS TRIGGER AS $$ +BEGIN + -- Solo si cambio el status + IF OLD.status IS DISTINCT FROM NEW.status THEN + INSERT INTO fiscal.cfdi_operation_logs ( + tenant_id, + cfdi_invoice_id, + cfdi_uuid, + operation_type, + status_before, + status_after, + success, + created_by + ) VALUES ( + NEW.tenant_id, + NEW.id, + NEW.uuid, + CASE + WHEN NEW.status = 'stamped' THEN 'stamp'::fiscal.cfdi_operation_type + WHEN NEW.status = 'cancelled' THEN 'cancel_complete'::fiscal.cfdi_operation_type + WHEN NEW.status = 'error' THEN 'error'::fiscal.cfdi_operation_type + ELSE 'create'::fiscal.cfdi_operation_type + END, + OLD.status::VARCHAR, + NEW.status::VARCHAR, + NEW.status NOT IN ('error'), + NEW.updated_by + ); + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_cfdi_status_log ON fiscal.cfdi_invoices; +CREATE TRIGGER trg_cfdi_status_log + AFTER UPDATE ON fiscal.cfdi_invoices + FOR EACH ROW EXECUTE FUNCTION fiscal.log_cfdi_status_change(); + +-- Trigger: Validar RFC antes de insertar CFDI +CREATE OR REPLACE FUNCTION fiscal.validate_cfdi_rfc() +RETURNS TRIGGER AS $$ +BEGIN + -- Validar RFC emisor + IF NOT fiscal.validate_rfc(NEW.issuer_rfc) THEN + RAISE EXCEPTION 'RFC del emisor invalido: %', NEW.issuer_rfc; + END IF; + + -- Validar RFC receptor + IF NOT fiscal.validate_rfc(NEW.receiver_rfc) THEN + RAISE EXCEPTION 'RFC del receptor invalido: %', NEW.receiver_rfc; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_cfdi_validate_rfc ON fiscal.cfdi_invoices; +CREATE TRIGGER trg_cfdi_validate_rfc + BEFORE INSERT OR UPDATE ON fiscal.cfdi_invoices + FOR EACH ROW EXECUTE FUNCTION fiscal.validate_cfdi_rfc(); + +-- ===================== +-- VISTAS +-- ===================== + +-- Vista: CFDIs pendientes de timbrar +CREATE OR REPLACE VIEW fiscal.v_cfdi_pending_stamp AS +SELECT + ci.id, + ci.tenant_id, + ci.invoice_id, + ci.serie, + ci.folio, + ci.voucher_type, + ci.status, + ci.issuer_rfc, + ci.receiver_rfc, + ci.receiver_name, + ci.total, + ci.created_at, + bi.invoice_number, + bi.invoice_date +FROM fiscal.cfdi_invoices ci +JOIN billing.invoices bi ON ci.invoice_id = bi.id +WHERE ci.status IN ('draft', 'pending') + AND ci.deleted_at IS NULL +ORDER BY ci.created_at; + +COMMENT ON VIEW fiscal.v_cfdi_pending_stamp IS 'CFDIs pendientes de timbrar'; + +-- Vista: Cancelaciones pendientes de respuesta +CREATE OR REPLACE VIEW fiscal.v_cfdi_pending_cancellations AS +SELECT + cr.id, + cr.tenant_id, + cr.cfdi_uuid, + cr.cancellation_reason, + cr.status, + cr.requested_at, + cr.submitted_at, + cr.expires_at, + ci.serie, + ci.folio, + ci.receiver_rfc, + ci.receiver_name, + ci.total +FROM fiscal.cfdi_cancellation_requests cr +JOIN fiscal.cfdi_invoices ci ON cr.cfdi_invoice_id = ci.id +WHERE cr.status IN ('submitted', 'in_process') +ORDER BY cr.expires_at; + +COMMENT ON VIEW fiscal.v_cfdi_pending_cancellations IS 'Solicitudes de cancelacion pendientes de respuesta'; + +-- Vista: Certificados por vencer +CREATE OR REPLACE VIEW fiscal.v_certificates_expiring AS +SELECT + cc.id, + cc.tenant_id, + cc.certificate_number, + cc.rfc, + cc.status, + cc.expires_at, + cc.is_default, + t.name as tenant_name, + (cc.expires_at - CURRENT_TIMESTAMP) as time_to_expire +FROM fiscal.cfdi_certificates cc +JOIN auth.tenants t ON cc.tenant_id = t.id +WHERE cc.deleted_at IS NULL + AND cc.status = 'active' + AND cc.expires_at < (CURRENT_TIMESTAMP + INTERVAL '30 days') +ORDER BY cc.expires_at; + +COMMENT ON VIEW fiscal.v_certificates_expiring IS 'Certificados CSD que vencen en los proximos 30 dias'; + +-- Vista: Resumen de uso de timbres por PAC +CREATE OR REPLACE VIEW fiscal.v_pac_stamp_usage AS +SELECT + pc.tenant_id, + pc.pac_code, + pc.pac_name, + pc.environment, + pc.monthly_stamp_limit, + pc.stamps_used_this_month, + CASE + WHEN pc.monthly_stamp_limit IS NULL THEN NULL + ELSE (pc.stamps_used_this_month::DECIMAL / pc.monthly_stamp_limit * 100) + END as usage_percentage, + pc.contract_expires_at, + t.name as tenant_name +FROM fiscal.cfdi_pac_configurations pc +JOIN auth.tenants t ON pc.tenant_id = t.id +WHERE pc.is_active = TRUE + AND pc.deleted_at IS NULL +ORDER BY pc.tenant_id, pc.pac_code; + +COMMENT ON VIEW fiscal.v_pac_stamp_usage IS 'Uso de timbres por PAC y tenant'; + +-- Vista: Facturas con saldo pendiente (PPD) +CREATE OR REPLACE VIEW fiscal.v_invoices_pending_payment AS +SELECT + ci.id, + ci.tenant_id, + ci.uuid, + ci.serie, + ci.folio, + ci.receiver_rfc, + ci.receiver_name, + ci.total, + ci.stamp_date, + COALESCE(SUM(pcd.amount_paid), 0) as total_paid, + ci.total - COALESCE(SUM(pcd.amount_paid), 0) as remaining_balance, + COUNT(pcd.id) as installment_count +FROM fiscal.cfdi_invoices ci +LEFT JOIN fiscal.cfdi_payment_complement_documents pcd + ON pcd.related_uuid = ci.uuid +LEFT JOIN fiscal.cfdi_payment_complements pc + ON pc.id = pcd.payment_complement_id + AND pc.status = 'stamped' +WHERE ci.payment_method = 'PPD' + AND ci.status = 'stamped' + AND ci.deleted_at IS NULL +GROUP BY ci.id +HAVING ci.total - COALESCE(SUM(pcd.amount_paid), 0) > 0 +ORDER BY ci.stamp_date; + +COMMENT ON VIEW fiscal.v_invoices_pending_payment IS 'Facturas PPD con saldo pendiente de complemento de pago'; + +-- ===================== +-- FIN DEL ARCHIVO +-- ===================== diff --git a/scripts/recreate-database.sh b/scripts/recreate-database.sh index 7a181d8..ead1deb 100755 --- a/scripts/recreate-database.sh +++ b/scripts/recreate-database.sh @@ -277,6 +277,8 @@ run_ddl_files() { # Orden especifico de ejecucion # Nota: El orden es importante por las dependencias entre tablas local ddl_files=( + # Auth base (tenants + users DDL - también creadas inline como safety net) + "00-auth-base.sql" # Core existente "01-auth-profiles.sql" "02-auth-devices.sql" @@ -286,11 +288,12 @@ run_ddl_files() { # SaaS Extensions - Sprint 1-2 (EPIC-SAAS-001, EPIC-SAAS-002) "06-auth-extended.sql" "07-users-rbac.sql" + "07a-permissions-seed.sql" "08-plans.sql" - "11-feature-flags.sql" # SaaS Extensions - Sprint 3+ (EPIC-SAAS-003 - EPIC-SAAS-008) "09-notifications.sql" "10-audit.sql" + "11-feature-flags.sql" "12-webhooks.sql" "13-storage.sql" "14-ai.sql" @@ -299,10 +302,37 @@ run_ddl_files() { "16-partners.sql" "17-products.sql" "18-warehouses.sql" + "19-product-attributes.sql" + "20-core-catalogs.sql" "21-inventory.sql" "22-sales.sql" "23-purchases.sql" "24-invoices.sql" + "25-payment-terminals.sql" + # Fiscal / CFDI + "26-fiscal-catalogs.sql" + "27-cfdi-core.sql" + "28-cfdi-operations.sql" + "29-cfdi-rls-functions.sql" + # Settings & Reports + "30-settings.sql" + "31-reports.sql" + # HR & Purchases Extended + "45-hr.sql" + "46-purchases-matching.sql" + # Financial Module + "50-financial-schema.sql" + "51-financial-accounts.sql" + "52-financial-journals.sql" + "53-financial-entries.sql" + "54-financial-invoices.sql" + "55-financial-payments.sql" + "56-financial-taxes.sql" + "57-financial-bank-reconciliation.sql" + # Projects + "60-projects-timesheets.sql" + # RLS (must be last - depends on all tables) + "99-rls-erp-modules.sql" ) for ddl_file in "${ddl_files[@]}"; do