-- ============================================================= -- ARCHIVO: 08-plans.sql -- DESCRIPCION: Extensiones de planes SaaS (features, limits, Stripe) -- VERSION: 1.0.0 -- PROYECTO: ERP-Core V2 -- FECHA: 2026-01-10 -- EPIC: SAAS-BILLING (EPIC-SAAS-002) -- HISTORIAS: US-007, US-010, US-011, US-012 -- ============================================================= -- ===================== -- MODIFICACIONES A TABLAS EXISTENTES -- ===================== -- Agregar columnas Stripe a tenant_subscriptions (US-007, US-008) ALTER TABLE billing.tenant_subscriptions ADD COLUMN IF NOT EXISTS stripe_customer_id VARCHAR(255), ADD COLUMN IF NOT EXISTS stripe_subscription_id VARCHAR(255), ADD COLUMN IF NOT EXISTS stripe_payment_method_id VARCHAR(255), ADD COLUMN IF NOT EXISTS stripe_price_id VARCHAR(255), ADD COLUMN IF NOT EXISTS trial_ends_at TIMESTAMPTZ, ADD COLUMN IF NOT EXISTS canceled_at TIMESTAMPTZ, ADD COLUMN IF NOT EXISTS cancel_at TIMESTAMPTZ; -- Agregar columnas Stripe a subscription_plans ALTER TABLE billing.subscription_plans ADD COLUMN IF NOT EXISTS stripe_product_id VARCHAR(255), ADD COLUMN IF NOT EXISTS stripe_price_id_monthly VARCHAR(255), ADD COLUMN IF NOT EXISTS stripe_price_id_annual VARCHAR(255); -- Agregar columnas Stripe a payment_methods ALTER TABLE billing.payment_methods ADD COLUMN IF NOT EXISTS stripe_payment_method_id VARCHAR(255); -- Indices para campos Stripe CREATE INDEX IF NOT EXISTS idx_subscriptions_stripe_customer ON billing.tenant_subscriptions(stripe_customer_id) WHERE stripe_customer_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_subscriptions_stripe_sub ON billing.tenant_subscriptions(stripe_subscription_id) WHERE stripe_subscription_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_plans_stripe_product ON billing.subscription_plans(stripe_product_id) WHERE stripe_product_id IS NOT NULL; -- ===================== -- TABLA: billing.plan_features -- Features habilitadas por plan (US-010, US-011) -- ===================== CREATE TABLE IF NOT EXISTS billing.plan_features ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), plan_id UUID NOT NULL REFERENCES billing.subscription_plans(id) ON DELETE CASCADE, -- Identificacion feature_key VARCHAR(100) NOT NULL, feature_name VARCHAR(255) NOT NULL, category VARCHAR(100), -- Estado enabled BOOLEAN DEFAULT TRUE, -- Configuracion configuration JSONB DEFAULT '{}', -- Metadata description TEXT, metadata JSONB DEFAULT '{}', created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, UNIQUE(plan_id, feature_key) ); -- Indices para plan_features CREATE INDEX IF NOT EXISTS idx_plan_features_plan ON billing.plan_features(plan_id); CREATE INDEX IF NOT EXISTS idx_plan_features_key ON billing.plan_features(feature_key); CREATE INDEX IF NOT EXISTS idx_plan_features_enabled ON billing.plan_features(plan_id, enabled) WHERE enabled = TRUE; -- ===================== -- TABLA: billing.plan_limits -- Limites cuantificables por plan (US-010, US-012) -- ===================== CREATE TABLE IF NOT EXISTS billing.plan_limits ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), plan_id UUID NOT NULL REFERENCES billing.subscription_plans(id) ON DELETE CASCADE, -- Identificacion limit_key VARCHAR(100) NOT NULL, limit_name VARCHAR(255) NOT NULL, -- Valor limit_value INTEGER NOT NULL, limit_type VARCHAR(50) DEFAULT 'monthly', -- monthly, daily, total, per_user -- Overage (si se permite exceder) allow_overage BOOLEAN DEFAULT FALSE, overage_unit_price DECIMAL(10,4) DEFAULT 0, overage_currency VARCHAR(3) DEFAULT 'MXN', -- Alertas alert_threshold_percent INTEGER DEFAULT 80, hard_limit BOOLEAN DEFAULT TRUE, -- Si true, bloquea; si false, solo alerta -- Metadata description TEXT, metadata JSONB DEFAULT '{}', created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, UNIQUE(plan_id, limit_key) ); -- Indices para plan_limits CREATE INDEX IF NOT EXISTS idx_plan_limits_plan ON billing.plan_limits(plan_id); CREATE INDEX IF NOT EXISTS idx_plan_limits_key ON billing.plan_limits(limit_key); -- ===================== -- TABLA: billing.coupons -- Cupones de descuento -- ===================== CREATE TABLE IF NOT EXISTS billing.coupons ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Identificacion code VARCHAR(50) NOT NULL UNIQUE, name VARCHAR(255) NOT NULL, description TEXT, -- Descuento discount_type VARCHAR(20) NOT NULL CHECK (discount_type IN ('percentage', 'fixed')), discount_value DECIMAL(10,2) NOT NULL, currency VARCHAR(3) DEFAULT 'MXN', -- Aplicabilidad applicable_plans UUID[] DEFAULT '{}', -- Vacio = todos min_amount DECIMAL(10,2) DEFAULT 0, -- Limites max_redemptions INTEGER, times_redeemed INTEGER DEFAULT 0, max_redemptions_per_customer INTEGER DEFAULT 1, -- Vigencia valid_from TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, valid_until TIMESTAMPTZ, -- Duracion del descuento duration VARCHAR(20) DEFAULT 'once', -- once, forever, repeating duration_months INTEGER, -- Si duration = repeating -- Stripe stripe_coupon_id VARCHAR(255), -- Estado is_active BOOLEAN DEFAULT TRUE, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP ); -- Indices para coupons CREATE INDEX IF NOT EXISTS idx_coupons_code ON billing.coupons(code); CREATE INDEX IF NOT EXISTS idx_coupons_active ON billing.coupons(is_active, valid_until); -- ===================== -- TABLA: billing.coupon_redemptions -- Uso de cupones -- ===================== CREATE TABLE IF NOT EXISTS billing.coupon_redemptions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), coupon_id UUID NOT NULL REFERENCES billing.coupons(id), tenant_id UUID NOT NULL REFERENCES auth.tenants(id), subscription_id UUID REFERENCES billing.tenant_subscriptions(id), -- Descuento aplicado discount_amount DECIMAL(10,2) NOT NULL, -- Timestamps redeemed_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, expires_at TIMESTAMPTZ, UNIQUE(coupon_id, tenant_id) ); -- Indices para coupon_redemptions CREATE INDEX IF NOT EXISTS idx_coupon_redemptions_coupon ON billing.coupon_redemptions(coupon_id); CREATE INDEX IF NOT EXISTS idx_coupon_redemptions_tenant ON billing.coupon_redemptions(tenant_id); -- ===================== -- TABLA: billing.stripe_events -- Log de eventos de Stripe (US-008) -- ===================== CREATE TABLE IF NOT EXISTS billing.stripe_events ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Evento de Stripe stripe_event_id VARCHAR(255) NOT NULL UNIQUE, event_type VARCHAR(100) NOT NULL, api_version VARCHAR(20), -- Datos data JSONB NOT NULL, -- Procesamiento processed BOOLEAN DEFAULT FALSE, processed_at TIMESTAMPTZ, error_message TEXT, retry_count INTEGER DEFAULT 0, -- Tenant relacionado (si aplica) tenant_id UUID REFERENCES auth.tenants(id), created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP ); -- Indices para stripe_events CREATE INDEX IF NOT EXISTS idx_stripe_events_type ON billing.stripe_events(event_type); CREATE INDEX IF NOT EXISTS idx_stripe_events_processed ON billing.stripe_events(processed) WHERE processed = FALSE; CREATE INDEX IF NOT EXISTS idx_stripe_events_tenant ON billing.stripe_events(tenant_id); -- ===================== -- RLS POLICIES -- ===================== ALTER TABLE billing.plan_features ENABLE ROW LEVEL SECURITY; -- Plan features son globales, no requieren isolation CREATE POLICY public_read_plan_features ON billing.plan_features FOR SELECT USING (true); ALTER TABLE billing.plan_limits ENABLE ROW LEVEL SECURITY; -- Plan limits son globales, no requieren isolation CREATE POLICY public_read_plan_limits ON billing.plan_limits FOR SELECT USING (true); ALTER TABLE billing.coupons ENABLE ROW LEVEL SECURITY; -- Cupones son globales pero solo admins pueden modificar CREATE POLICY public_read_coupons ON billing.coupons FOR SELECT USING (is_active = TRUE); ALTER TABLE billing.coupon_redemptions ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_coupon_redemptions ON billing.coupon_redemptions USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); ALTER TABLE billing.stripe_events ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_stripe_events ON billing.stripe_events USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid OR tenant_id IS NULL); -- ===================== -- FUNCIONES -- ===================== -- Funcion para verificar si un tenant tiene una feature habilitada CREATE OR REPLACE FUNCTION billing.has_feature( p_tenant_id UUID, p_feature_key VARCHAR(100) ) RETURNS BOOLEAN AS $$ DECLARE v_enabled BOOLEAN; BEGIN SELECT pf.enabled INTO v_enabled FROM billing.tenant_subscriptions ts JOIN billing.plan_features pf ON pf.plan_id = ts.plan_id WHERE ts.tenant_id = p_tenant_id AND ts.status = 'active' AND pf.feature_key = p_feature_key; RETURN COALESCE(v_enabled, FALSE); END; $$ LANGUAGE plpgsql STABLE; -- Funcion para obtener limite de un tenant CREATE OR REPLACE FUNCTION billing.get_limit( p_tenant_id UUID, p_limit_key VARCHAR(100) ) RETURNS INTEGER AS $$ DECLARE v_limit INTEGER; BEGIN SELECT pl.limit_value INTO v_limit FROM billing.tenant_subscriptions ts JOIN billing.plan_limits pl ON pl.plan_id = ts.plan_id WHERE ts.tenant_id = p_tenant_id AND ts.status = 'active' AND pl.limit_key = p_limit_key; RETURN COALESCE(v_limit, 0); END; $$ LANGUAGE plpgsql STABLE; -- Funcion para verificar si se puede usar una feature (con limite) CREATE OR REPLACE FUNCTION billing.can_use_feature( p_tenant_id UUID, p_limit_key VARCHAR(100), p_current_usage INTEGER DEFAULT 0 ) RETURNS TABLE ( allowed BOOLEAN, limit_value INTEGER, current_usage INTEGER, remaining INTEGER, allow_overage BOOLEAN, overage_price DECIMAL ) AS $$ BEGIN RETURN QUERY SELECT CASE WHEN pl.hard_limit THEN p_current_usage < pl.limit_value ELSE TRUE END as allowed, pl.limit_value, p_current_usage as current_usage, GREATEST(0, pl.limit_value - p_current_usage) as remaining, pl.allow_overage, pl.overage_unit_price FROM billing.tenant_subscriptions ts JOIN billing.plan_limits pl ON pl.plan_id = ts.plan_id WHERE ts.tenant_id = p_tenant_id AND ts.status = 'active' AND pl.limit_key = p_limit_key; END; $$ LANGUAGE plpgsql STABLE; -- Funcion para obtener todas las features de un tenant CREATE OR REPLACE FUNCTION billing.get_tenant_features(p_tenant_id UUID) RETURNS TABLE ( feature_key VARCHAR(100), feature_name VARCHAR(255), enabled BOOLEAN, configuration JSONB ) AS $$ BEGIN RETURN QUERY SELECT pf.feature_key, pf.feature_name, pf.enabled, pf.configuration FROM billing.tenant_subscriptions ts JOIN billing.plan_features pf ON pf.plan_id = ts.plan_id WHERE ts.tenant_id = p_tenant_id AND ts.status IN ('active', 'trial'); END; $$ LANGUAGE plpgsql STABLE; -- Funcion para obtener todos los limites de un tenant CREATE OR REPLACE FUNCTION billing.get_tenant_limits(p_tenant_id UUID) RETURNS TABLE ( limit_key VARCHAR(100), limit_name VARCHAR(255), limit_value INTEGER, limit_type VARCHAR(50), allow_overage BOOLEAN, overage_unit_price DECIMAL ) AS $$ BEGIN RETURN QUERY SELECT pl.limit_key, pl.limit_name, pl.limit_value, pl.limit_type, pl.allow_overage, pl.overage_unit_price FROM billing.tenant_subscriptions ts JOIN billing.plan_limits pl ON pl.plan_id = ts.plan_id WHERE ts.tenant_id = p_tenant_id AND ts.status IN ('active', 'trial'); END; $$ LANGUAGE plpgsql STABLE; -- Funcion para aplicar cupon CREATE OR REPLACE FUNCTION billing.apply_coupon( p_tenant_id UUID, p_coupon_code VARCHAR(50) ) RETURNS TABLE ( success BOOLEAN, message TEXT, discount_amount DECIMAL ) AS $$ DECLARE v_coupon RECORD; v_subscription RECORD; v_discount DECIMAL; BEGIN -- Obtener cupon SELECT * INTO v_coupon FROM billing.coupons WHERE code = p_coupon_code AND is_active = TRUE AND (valid_until IS NULL OR valid_until > CURRENT_TIMESTAMP); IF NOT FOUND THEN RETURN QUERY SELECT FALSE, 'Cupon no valido o expirado'::TEXT, 0::DECIMAL; RETURN; END IF; -- Verificar max redemptions IF v_coupon.max_redemptions IS NOT NULL AND v_coupon.times_redeemed >= v_coupon.max_redemptions THEN RETURN QUERY SELECT FALSE, 'Cupon agotado'::TEXT, 0::DECIMAL; RETURN; END IF; -- Verificar si ya fue usado por este tenant IF EXISTS (SELECT 1 FROM billing.coupon_redemptions WHERE coupon_id = v_coupon.id AND tenant_id = p_tenant_id) THEN RETURN QUERY SELECT FALSE, 'Cupon ya utilizado'::TEXT, 0::DECIMAL; RETURN; END IF; -- Obtener suscripcion SELECT * INTO v_subscription FROM billing.tenant_subscriptions WHERE tenant_id = p_tenant_id AND status = 'active'; IF NOT FOUND THEN RETURN QUERY SELECT FALSE, 'No hay suscripcion activa'::TEXT, 0::DECIMAL; RETURN; END IF; -- Calcular descuento IF v_coupon.discount_type = 'percentage' THEN v_discount := v_subscription.current_price * (v_coupon.discount_value / 100); ELSE v_discount := v_coupon.discount_value; END IF; -- Registrar uso INSERT INTO billing.coupon_redemptions (coupon_id, tenant_id, subscription_id, discount_amount) VALUES (v_coupon.id, p_tenant_id, v_subscription.id, v_discount); -- Actualizar contador UPDATE billing.coupons SET times_redeemed = times_redeemed + 1 WHERE id = v_coupon.id; RETURN QUERY SELECT TRUE, 'Cupon aplicado correctamente'::TEXT, v_discount; END; $$ LANGUAGE plpgsql; -- ===================== -- TRIGGERS -- ===================== -- Trigger para updated_at en plan_features CREATE OR REPLACE FUNCTION billing.update_plan_features_timestamp() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = CURRENT_TIMESTAMP; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_plan_features_updated_at BEFORE UPDATE ON billing.plan_features FOR EACH ROW EXECUTE FUNCTION billing.update_plan_features_timestamp(); CREATE TRIGGER trg_plan_limits_updated_at BEFORE UPDATE ON billing.plan_limits FOR EACH ROW EXECUTE FUNCTION billing.update_plan_features_timestamp(); -- ===================== -- SEED DATA: Features por Plan -- ===================== -- Features para plan Starter INSERT INTO billing.plan_features (plan_id, feature_key, feature_name, category, enabled) VALUES ((SELECT id FROM billing.subscription_plans WHERE code = 'starter'), 'pos', 'Punto de Venta', 'sales', TRUE), ((SELECT id FROM billing.subscription_plans WHERE code = 'starter'), 'inventory_basic', 'Inventario Basico', 'inventory', TRUE), ((SELECT id FROM billing.subscription_plans WHERE code = 'starter'), 'reports_basic', 'Reportes Basicos', 'reports', TRUE), ((SELECT id FROM billing.subscription_plans WHERE code = 'starter'), 'email_support', 'Soporte por Email', 'support', TRUE) ON CONFLICT DO NOTHING; -- Features para plan Professional INSERT INTO billing.plan_features (plan_id, feature_key, feature_name, category, enabled) VALUES ((SELECT id FROM billing.subscription_plans WHERE code = 'professional'), 'pos', 'Punto de Venta', 'sales', TRUE), ((SELECT id FROM billing.subscription_plans WHERE code = 'professional'), 'mobile_app', 'App Movil', 'platform', TRUE), ((SELECT id FROM billing.subscription_plans WHERE code = 'professional'), 'inventory_advanced', 'Inventario Avanzado', 'inventory', TRUE), ((SELECT id FROM billing.subscription_plans WHERE code = 'professional'), 'reports_advanced', 'Reportes Avanzados', 'reports', TRUE), ((SELECT id FROM billing.subscription_plans WHERE code = 'professional'), 'multi_branch', 'Multi-Sucursal', 'core', TRUE), ((SELECT id FROM billing.subscription_plans WHERE code = 'professional'), 'api_access', 'Acceso a API', 'integration', TRUE), ((SELECT id FROM billing.subscription_plans WHERE code = 'professional'), 'chat_support', 'Soporte por Chat', 'support', TRUE) ON CONFLICT DO NOTHING; -- Features para plan Business INSERT INTO billing.plan_features (plan_id, feature_key, feature_name, category, enabled) VALUES ((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'pos', 'Punto de Venta', 'sales', TRUE), ((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'mobile_app', 'App Movil', 'platform', TRUE), ((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'desktop_app', 'App Desktop', 'platform', TRUE), ((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'inventory_advanced', 'Inventario Avanzado', 'inventory', TRUE), ((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'manufacturing', 'Manufactura', 'production', TRUE), ((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'hr_module', 'Modulo RRHH', 'hr', TRUE), ((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'accounting', 'Contabilidad', 'financial', TRUE), ((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'ai_assistant', 'Asistente IA', 'ai', TRUE), ((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'webhooks', 'Webhooks', 'integration', TRUE), ((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'priority_support', 'Soporte Prioritario', 'support', TRUE), ((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'sla_99', 'SLA 99.9%', 'support', TRUE) ON CONFLICT DO NOTHING; -- ===================== -- SEED DATA: Limits por Plan -- ===================== -- Limits para plan Starter INSERT INTO billing.plan_limits (plan_id, limit_key, limit_name, limit_value, limit_type, allow_overage) VALUES ((SELECT id FROM billing.subscription_plans WHERE code = 'starter'), 'users', 'Usuarios', 3, 'total', FALSE), ((SELECT id FROM billing.subscription_plans WHERE code = 'starter'), 'branches', 'Sucursales', 1, 'total', FALSE), ((SELECT id FROM billing.subscription_plans WHERE code = 'starter'), 'storage_mb', 'Storage (MB)', 5120, 'total', TRUE), ((SELECT id FROM billing.subscription_plans WHERE code = 'starter'), 'api_calls', 'API Calls', 5000, 'monthly', FALSE), ((SELECT id FROM billing.subscription_plans WHERE code = 'starter'), 'invoices', 'Facturas', 100, 'monthly', TRUE) ON CONFLICT DO NOTHING; -- Limits para plan Professional INSERT INTO billing.plan_limits (plan_id, limit_key, limit_name, limit_value, limit_type, allow_overage, overage_unit_price) VALUES ((SELECT id FROM billing.subscription_plans WHERE code = 'professional'), 'users', 'Usuarios', 10, 'total', TRUE, 99), ((SELECT id FROM billing.subscription_plans WHERE code = 'professional'), 'branches', 'Sucursales', 3, 'total', TRUE, 199), ((SELECT id FROM billing.subscription_plans WHERE code = 'professional'), 'storage_mb', 'Storage (MB)', 20480, 'total', TRUE, 0.10), ((SELECT id FROM billing.subscription_plans WHERE code = 'professional'), 'api_calls', 'API Calls', 25000, 'monthly', TRUE, 0.001), ((SELECT id FROM billing.subscription_plans WHERE code = 'professional'), 'invoices', 'Facturas', 500, 'monthly', TRUE, 2) ON CONFLICT DO NOTHING; -- Limits para plan Business INSERT INTO billing.plan_limits (plan_id, limit_key, limit_name, limit_value, limit_type, allow_overage, overage_unit_price) VALUES ((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'users', 'Usuarios', 25, 'total', TRUE, 79), ((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'branches', 'Sucursales', 10, 'total', TRUE, 149), ((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'storage_mb', 'Storage (MB)', 102400, 'total', TRUE, 0.05), ((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'api_calls', 'API Calls', 100000, 'monthly', TRUE, 0.0005), ((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'invoices', 'Facturas', 0, 'monthly', FALSE, 0), -- Ilimitadas ((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'ai_tokens', 'Tokens IA', 100000, 'monthly', TRUE, 0.0001) ON CONFLICT DO NOTHING; -- ===================== -- COMENTARIOS -- ===================== COMMENT ON TABLE billing.plan_features IS 'Features habilitadas por plan de suscripcion'; COMMENT ON TABLE billing.plan_limits IS 'Limites cuantificables por plan'; COMMENT ON TABLE billing.coupons IS 'Cupones de descuento'; COMMENT ON TABLE billing.coupon_redemptions IS 'Registro de uso de cupones'; COMMENT ON TABLE billing.stripe_events IS 'Log de eventos recibidos de Stripe'; COMMENT ON FUNCTION billing.has_feature IS 'Verifica si un tenant tiene una feature habilitada'; COMMENT ON FUNCTION billing.get_limit IS 'Obtiene el valor de un limite para un tenant'; COMMENT ON FUNCTION billing.can_use_feature IS 'Verifica si un tenant puede usar una feature con limite'; COMMENT ON FUNCTION billing.get_tenant_features IS 'Obtiene todas las features habilitadas para un tenant'; COMMENT ON FUNCTION billing.get_tenant_limits IS 'Obtiene todos los limites de un tenant'; COMMENT ON FUNCTION billing.apply_coupon IS 'Aplica un cupon de descuento a un tenant';