-- ============================================================= -- ARCHIVO: 05-billing-usage.sql -- DESCRIPCION: Facturacion por uso, tracking de consumo, suscripciones -- VERSION: 1.0.0 -- PROYECTO: ERP-Core V2 -- FECHA: 2026-01-10 -- ============================================================= -- ===================== -- SCHEMA: billing -- ===================== CREATE SCHEMA IF NOT EXISTS billing; -- ===================== -- TABLA: subscription_plans -- Planes de suscripcion disponibles -- ===================== CREATE TABLE IF NOT EXISTS billing.subscription_plans ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Identificacion code VARCHAR(30) NOT NULL UNIQUE, name VARCHAR(100) NOT NULL, description TEXT, -- Tipo plan_type VARCHAR(20) NOT NULL DEFAULT 'saas', -- saas, on_premise, hybrid -- Precios base base_monthly_price DECIMAL(12,2) NOT NULL DEFAULT 0, base_annual_price DECIMAL(12,2), -- Precio anual con descuento setup_fee DECIMAL(12,2) DEFAULT 0, -- Limites base max_users INTEGER DEFAULT 5, max_branches INTEGER DEFAULT 1, storage_gb INTEGER DEFAULT 10, api_calls_monthly INTEGER DEFAULT 10000, -- Modulos incluidos included_modules TEXT[] DEFAULT '{}', -- Plataformas incluidas included_platforms TEXT[] DEFAULT '{web}', -- Features features JSONB DEFAULT '{}', -- Estado is_active BOOLEAN DEFAULT TRUE, is_public BOOLEAN DEFAULT TRUE, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, deleted_at TIMESTAMPTZ ); -- ===================== -- TABLA: tenant_subscriptions -- Suscripciones activas de tenants -- ===================== CREATE TABLE IF NOT EXISTS billing.tenant_subscriptions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, plan_id UUID NOT NULL REFERENCES billing.subscription_plans(id), -- Periodo billing_cycle VARCHAR(20) NOT NULL DEFAULT 'monthly', -- monthly, annual current_period_start TIMESTAMPTZ NOT NULL, current_period_end TIMESTAMPTZ NOT NULL, -- Estado status VARCHAR(20) NOT NULL DEFAULT 'active', -- trial, active, past_due, cancelled, suspended -- Trial trial_start TIMESTAMPTZ, trial_end TIMESTAMPTZ, -- Configuracion de facturacion billing_email VARCHAR(255), billing_name VARCHAR(200), billing_address JSONB DEFAULT '{}', tax_id VARCHAR(20), -- RFC para Mexico -- Metodo de pago payment_method_id UUID, payment_provider VARCHAR(30), -- stripe, mercadopago, bank_transfer -- Precios actuales (pueden diferir del plan por descuentos) current_price DECIMAL(12,2) NOT NULL, discount_percent DECIMAL(5,2) DEFAULT 0, discount_reason VARCHAR(100), -- Uso contratado contracted_users INTEGER, contracted_branches INTEGER, -- Facturacion automatica auto_renew BOOLEAN DEFAULT TRUE, next_invoice_date DATE, -- Cancelacion cancel_at_period_end BOOLEAN DEFAULT FALSE, cancelled_at TIMESTAMPTZ, cancellation_reason TEXT, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, UNIQUE(tenant_id) ); -- Indices para tenant_subscriptions CREATE INDEX IF NOT EXISTS idx_subscriptions_tenant ON billing.tenant_subscriptions(tenant_id); CREATE INDEX IF NOT EXISTS idx_subscriptions_plan ON billing.tenant_subscriptions(plan_id); CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON billing.tenant_subscriptions(status); CREATE INDEX IF NOT EXISTS idx_subscriptions_period_end ON billing.tenant_subscriptions(current_period_end); -- ===================== -- TABLA: usage_tracking -- Tracking de uso por tenant -- ===================== CREATE TABLE IF NOT EXISTS billing.usage_tracking ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, -- Periodo period_start DATE NOT NULL, period_end DATE NOT NULL, -- Usuarios active_users INTEGER DEFAULT 0, peak_concurrent_users INTEGER DEFAULT 0, -- Por perfil users_by_profile JSONB DEFAULT '{}', -- Ejemplo: {"ADM": 2, "VNT": 5, "ALM": 3} -- Por plataforma users_by_platform JSONB DEFAULT '{}', -- Ejemplo: {"web": 8, "mobile": 5, "desktop": 0} -- Sucursales active_branches INTEGER DEFAULT 0, -- Storage storage_used_gb DECIMAL(10,2) DEFAULT 0, documents_count INTEGER DEFAULT 0, -- API api_calls INTEGER DEFAULT 0, api_errors INTEGER DEFAULT 0, -- Transacciones sales_count INTEGER DEFAULT 0, sales_amount DECIMAL(14,2) DEFAULT 0, invoices_generated INTEGER DEFAULT 0, -- Mobile mobile_sessions INTEGER DEFAULT 0, offline_syncs INTEGER DEFAULT 0, payment_transactions INTEGER DEFAULT 0, -- Calculado total_billable_amount DECIMAL(12,2) DEFAULT 0, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, UNIQUE(tenant_id, period_start) ); -- Indices para usage_tracking CREATE INDEX IF NOT EXISTS idx_usage_tenant ON billing.usage_tracking(tenant_id); CREATE INDEX IF NOT EXISTS idx_usage_period ON billing.usage_tracking(period_start, period_end); -- ===================== -- TABLA: usage_events -- Eventos de uso en tiempo real (para calculo de billing) -- ===================== CREATE TABLE IF NOT EXISTS billing.usage_events ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, user_id UUID REFERENCES auth.users(id), device_id UUID REFERENCES auth.devices(id), branch_id UUID REFERENCES core.branches(id), -- Evento event_type VARCHAR(50) NOT NULL, -- login, api_call, document_upload, sale, invoice, sync event_category VARCHAR(30) NOT NULL, -- user, api, storage, transaction, mobile -- Detalles profile_code VARCHAR(10), platform VARCHAR(20), resource_id UUID, resource_type VARCHAR(50), -- Metricas quantity INTEGER DEFAULT 1, bytes_used BIGINT DEFAULT 0, duration_ms INTEGER, -- Metadata metadata JSONB DEFAULT '{}', created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP ); -- Indices para usage_events (particionado por fecha recomendado) CREATE INDEX IF NOT EXISTS idx_usage_events_tenant ON billing.usage_events(tenant_id); CREATE INDEX IF NOT EXISTS idx_usage_events_type ON billing.usage_events(event_type); CREATE INDEX IF NOT EXISTS idx_usage_events_category ON billing.usage_events(event_category); CREATE INDEX IF NOT EXISTS idx_usage_events_created ON billing.usage_events(created_at DESC); -- ===================== -- TABLA: invoices -- Facturas generadas -- ===================== CREATE TABLE IF NOT EXISTS billing.invoices ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, subscription_id UUID REFERENCES billing.tenant_subscriptions(id), -- Numero de factura invoice_number VARCHAR(30) NOT NULL UNIQUE, invoice_date DATE NOT NULL, -- Periodo facturado period_start DATE NOT NULL, period_end DATE NOT NULL, -- Cliente billing_name VARCHAR(200), billing_email VARCHAR(255), billing_address JSONB DEFAULT '{}', tax_id VARCHAR(20), -- Montos subtotal DECIMAL(12,2) NOT NULL, tax_amount DECIMAL(12,2) DEFAULT 0, discount_amount DECIMAL(12,2) DEFAULT 0, total DECIMAL(12,2) NOT NULL, currency VARCHAR(3) DEFAULT 'MXN', -- Estado status VARCHAR(20) NOT NULL DEFAULT 'draft', -- draft, sent, paid, partial, overdue, void, refunded -- Fechas de pago due_date DATE NOT NULL, paid_at TIMESTAMPTZ, paid_amount DECIMAL(12,2) DEFAULT 0, -- Detalles de pago payment_method VARCHAR(30), payment_reference VARCHAR(100), -- CFDI (para Mexico) cfdi_uuid VARCHAR(36), cfdi_xml TEXT, cfdi_pdf_url TEXT, -- Metadata notes TEXT, internal_notes TEXT, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP ); -- Indices para invoices CREATE INDEX IF NOT EXISTS idx_invoices_tenant ON billing.invoices(tenant_id); CREATE INDEX IF NOT EXISTS idx_invoices_subscription ON billing.invoices(subscription_id); CREATE INDEX IF NOT EXISTS idx_invoices_number ON billing.invoices(invoice_number); 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 DESC); CREATE INDEX IF NOT EXISTS idx_invoices_due ON billing.invoices(due_date) WHERE status IN ('sent', 'partial', 'overdue'); -- ===================== -- TABLA: invoice_items -- Items de cada factura -- ===================== CREATE TABLE IF NOT EXISTS billing.invoice_items ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), invoice_id UUID NOT NULL REFERENCES billing.invoices(id) ON DELETE CASCADE, -- Descripcion description VARCHAR(500) NOT NULL, item_type VARCHAR(30) NOT NULL, -- subscription, user, profile, overage, addon -- Cantidades quantity INTEGER NOT NULL DEFAULT 1, unit_price DECIMAL(12,2) NOT NULL, subtotal DECIMAL(12,2) NOT NULL, -- Detalles adicionales profile_code VARCHAR(10), -- Si es cargo por perfil platform VARCHAR(20), -- Si es cargo por plataforma period_start DATE, period_end DATE, -- Metadata metadata JSONB DEFAULT '{}', created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP ); -- 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_type ON billing.invoice_items(item_type); -- ===================== -- TABLA: payment_methods -- Metodos de pago guardados -- ===================== CREATE TABLE IF NOT EXISTS billing.payment_methods ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, -- Proveedor provider VARCHAR(30) NOT NULL, -- stripe, mercadopago, bank_transfer -- Tipo method_type VARCHAR(20) NOT NULL, -- card, bank_account, wallet -- Datos (encriptados/tokenizados) provider_customer_id VARCHAR(255), provider_method_id VARCHAR(255), -- Display info (no sensible) display_name VARCHAR(100), card_brand VARCHAR(20), card_last_four VARCHAR(4), card_exp_month INTEGER, card_exp_year INTEGER, bank_name VARCHAR(100), bank_last_four VARCHAR(4), -- Estado is_default BOOLEAN DEFAULT FALSE, is_active BOOLEAN DEFAULT TRUE, is_verified BOOLEAN DEFAULT FALSE, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, deleted_at TIMESTAMPTZ ); -- Indices para payment_methods CREATE INDEX IF NOT EXISTS idx_payment_methods_tenant ON billing.payment_methods(tenant_id); CREATE INDEX IF NOT EXISTS idx_payment_methods_provider ON billing.payment_methods(provider); CREATE INDEX IF NOT EXISTS idx_payment_methods_default ON billing.payment_methods(is_default) WHERE is_default = TRUE; -- ===================== -- TABLA: billing_alerts -- Alertas de facturacion -- ===================== CREATE TABLE IF NOT EXISTS billing.billing_alerts ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, -- Tipo de alerta alert_type VARCHAR(30) NOT NULL, -- usage_limit, payment_due, payment_failed, trial_ending, subscription_ending -- Detalles title VARCHAR(200) NOT NULL, message TEXT, severity VARCHAR(20) NOT NULL DEFAULT 'info', -- info, warning, critical -- Estado status VARCHAR(20) NOT NULL DEFAULT 'active', -- active, acknowledged, resolved -- Notificacion notified_at TIMESTAMPTZ, acknowledged_at TIMESTAMPTZ, acknowledged_by UUID REFERENCES auth.users(id), -- Metadata metadata JSONB DEFAULT '{}', created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP ); -- Indices para billing_alerts CREATE INDEX IF NOT EXISTS idx_billing_alerts_tenant ON billing.billing_alerts(tenant_id); CREATE INDEX IF NOT EXISTS idx_billing_alerts_type ON billing.billing_alerts(alert_type); CREATE INDEX IF NOT EXISTS idx_billing_alerts_status ON billing.billing_alerts(status) WHERE status = 'active'; -- ===================== -- RLS POLICIES -- ===================== ALTER TABLE billing.tenant_subscriptions ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_subscriptions ON billing.tenant_subscriptions USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); ALTER TABLE billing.usage_tracking ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_usage ON billing.usage_tracking USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); ALTER TABLE billing.usage_events ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_usage_events ON billing.usage_events USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); ALTER TABLE billing.invoices ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_invoices ON billing.invoices USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); ALTER TABLE billing.invoice_items ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_invoice_items ON billing.invoice_items USING (invoice_id IN ( SELECT id FROM billing.invoices WHERE tenant_id = current_setting('app.current_tenant_id', true)::uuid )); ALTER TABLE billing.payment_methods ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_payment_methods ON billing.payment_methods USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); ALTER TABLE billing.billing_alerts ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_billing_alerts ON billing.billing_alerts USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); -- ===================== -- FUNCIONES -- ===================== -- Funcion para calcular uso mensual de un tenant CREATE OR REPLACE FUNCTION billing.calculate_monthly_usage( p_tenant_id UUID, p_period_start DATE, p_period_end DATE ) RETURNS TABLE ( active_users INTEGER, users_by_profile JSONB, users_by_platform JSONB, active_branches INTEGER, storage_used_gb DECIMAL, api_calls INTEGER, total_billable DECIMAL ) AS $$ BEGIN RETURN QUERY WITH user_stats AS ( SELECT COUNT(DISTINCT ue.user_id) as active_users, jsonb_object_agg( COALESCE(ue.profile_code, 'unknown'), COUNT(DISTINCT ue.user_id) ) as by_profile, jsonb_object_agg( COALESCE(ue.platform, 'unknown'), COUNT(DISTINCT ue.user_id) ) as by_platform FROM billing.usage_events ue WHERE ue.tenant_id = p_tenant_id AND ue.created_at >= p_period_start AND ue.created_at < p_period_end AND ue.event_category = 'user' ), branch_stats AS ( SELECT COUNT(DISTINCT branch_id) as active_branches FROM billing.usage_events WHERE tenant_id = p_tenant_id AND created_at >= p_period_start AND created_at < p_period_end AND branch_id IS NOT NULL ), storage_stats AS ( SELECT COALESCE(SUM(bytes_used), 0)::DECIMAL / (1024*1024*1024) as storage_gb FROM billing.usage_events WHERE tenant_id = p_tenant_id AND created_at >= p_period_start AND created_at < p_period_end AND event_category = 'storage' ), api_stats AS ( SELECT COUNT(*) as api_calls FROM billing.usage_events WHERE tenant_id = p_tenant_id AND created_at >= p_period_start AND created_at < p_period_end AND event_category = 'api' ) SELECT us.active_users::INTEGER, us.by_profile, us.by_platform, bs.active_branches::INTEGER, ss.storage_gb, api.api_calls::INTEGER, 0::DECIMAL as total_billable -- Se calcula aparte basado en plan FROM user_stats us, branch_stats bs, storage_stats ss, api_stats api; END; $$ LANGUAGE plpgsql; -- Funcion para generar numero de factura CREATE OR REPLACE FUNCTION billing.generate_invoice_number() RETURNS VARCHAR(30) AS $$ DECLARE v_year VARCHAR(4); v_sequence INTEGER; v_number VARCHAR(30); BEGIN v_year := to_char(CURRENT_DATE, 'YYYY'); SELECT COALESCE(MAX( CAST(SUBSTRING(invoice_number FROM 6 FOR 6) AS INTEGER) ), 0) + 1 INTO v_sequence FROM billing.invoices WHERE invoice_number LIKE 'INV-' || v_year || '-%'; v_number := 'INV-' || v_year || '-' || LPAD(v_sequence::TEXT, 6, '0'); RETURN v_number; END; $$ LANGUAGE plpgsql; -- Funcion para verificar limites de uso CREATE OR REPLACE FUNCTION billing.check_usage_limits(p_tenant_id UUID) RETURNS TABLE ( limit_type VARCHAR, current_value INTEGER, max_value INTEGER, percentage DECIMAL, is_exceeded BOOLEAN ) AS $$ BEGIN RETURN QUERY WITH subscription AS ( SELECT ts.contracted_users, ts.contracted_branches, sp.storage_gb, sp.api_calls_monthly FROM billing.tenant_subscriptions ts JOIN billing.subscription_plans sp ON sp.id = ts.plan_id WHERE ts.tenant_id = p_tenant_id AND ts.status = 'active' ), current_usage AS ( SELECT ut.active_users, ut.active_branches, ut.storage_used_gb::INTEGER, ut.api_calls FROM billing.usage_tracking ut WHERE ut.tenant_id = p_tenant_id AND ut.period_start <= CURRENT_DATE AND ut.period_end >= CURRENT_DATE ) SELECT 'users'::VARCHAR as limit_type, COALESCE(cu.active_users, 0) as current_value, s.contracted_users as max_value, CASE WHEN s.contracted_users > 0 THEN (COALESCE(cu.active_users, 0)::DECIMAL / s.contracted_users * 100) ELSE 0 END as percentage, COALESCE(cu.active_users, 0) > s.contracted_users as is_exceeded FROM subscription s, current_usage cu UNION ALL SELECT 'branches'::VARCHAR, COALESCE(cu.active_branches, 0), s.contracted_branches, CASE WHEN s.contracted_branches > 0 THEN (COALESCE(cu.active_branches, 0)::DECIMAL / s.contracted_branches * 100) ELSE 0 END, COALESCE(cu.active_branches, 0) > s.contracted_branches FROM subscription s, current_usage cu UNION ALL SELECT 'storage'::VARCHAR, COALESCE(cu.storage_used_gb, 0), s.storage_gb, CASE WHEN s.storage_gb > 0 THEN (COALESCE(cu.storage_used_gb, 0)::DECIMAL / s.storage_gb * 100) ELSE 0 END, COALESCE(cu.storage_used_gb, 0) > s.storage_gb FROM subscription s, current_usage cu UNION ALL SELECT 'api_calls'::VARCHAR, COALESCE(cu.api_calls, 0), s.api_calls_monthly, CASE WHEN s.api_calls_monthly > 0 THEN (COALESCE(cu.api_calls, 0)::DECIMAL / s.api_calls_monthly * 100) ELSE 0 END, COALESCE(cu.api_calls, 0) > s.api_calls_monthly FROM subscription s, current_usage cu; END; $$ LANGUAGE plpgsql; -- ===================== -- SEED DATA: Planes de suscripcion -- ===================== INSERT INTO billing.subscription_plans (code, name, description, plan_type, base_monthly_price, max_users, max_branches, storage_gb, api_calls_monthly, included_modules, included_platforms) VALUES ('starter', 'Starter', 'Plan basico para pequenos negocios', 'saas', 499, 3, 1, 5, 5000, '{core,sales,inventory}', '{web}'), ('professional', 'Professional', 'Plan profesional con app movil', 'saas', 999, 10, 3, 20, 25000, '{core,sales,inventory,purchases,financial,reports}', '{web,mobile}'), ('business', 'Business', 'Plan empresarial completo', 'saas', 2499, 25, 10, 100, 100000, '{all}', '{web,mobile,desktop}'), ('enterprise', 'Enterprise', 'Plan enterprise personalizado', 'saas', 0, 0, 0, 0, 0, '{all}', '{web,mobile,desktop}'), ('on_premise', 'On-Premise', 'Instalacion en servidor propio', 'on_premise', 0, 0, 0, 0, 0, '{all}', '{web,mobile,desktop}') ON CONFLICT DO NOTHING; -- ===================== -- COMENTARIOS DE TABLAS -- ===================== COMMENT ON TABLE billing.subscription_plans IS 'Planes de suscripcion disponibles para tenants'; COMMENT ON TABLE billing.tenant_subscriptions IS 'Suscripciones activas de cada tenant'; COMMENT ON TABLE billing.usage_tracking IS 'Resumen de uso por periodo para calculo de facturacion'; COMMENT ON TABLE billing.usage_events IS 'Eventos de uso en tiempo real para tracking granular'; COMMENT ON TABLE billing.invoices IS 'Facturas generadas para cada tenant'; COMMENT ON TABLE billing.invoice_items IS 'Items detallados de cada factura'; COMMENT ON TABLE billing.payment_methods IS 'Metodos de pago guardados por tenant'; COMMENT ON TABLE billing.billing_alerts IS 'Alertas de facturacion y limites de uso';