[TASK-2026-02-05-EJECUCION-REMEDIATION-ERP-CORE] feat: DDL fixes and new schemas

- 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 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-02-05 21:52:22 -06:00
parent c9ddeb8b5a
commit 02ab2caf26
8 changed files with 2133 additions and 46 deletions

52
ddl/00-auth-base.sql Normal file
View File

@ -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);

View File

@ -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';

View File

@ -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)';

View File

@ -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';

460
ddl/27-cfdi-core.sql Normal file
View File

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

442
ddl/28-cfdi-operations.sql Normal file
View File

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

View File

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

View File

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