Dominios y registradores: TLDs, precios y tu propio Registrar_Adapter

Por: Artiko
fossbillingdominiosregistradorestldnamecheapresellerclubeppwhoisregistrar-adapter

Dominios y registradores: TLDs, precios y tu propio Registrar_Adapter

En el capítulo 11 conectaste FOSSBilling a un panel de hosting y viste cómo un pedido pagado se convierte en una cuenta creada por API. Los dominios funcionan con la misma coreografía —pedido, pago, activación, llamada al proveedor— pero con dos diferencias que se pagan caras si las descubres en producción.

La primera es económica: FOSSBilling no consulta tarifas al registrador. No hay sincronización de precios, no hay catálogo de TLDs importable, no hay “actualizar precios desde Namecheap”. Cada extensión que vendas la das de alta a mano con sus tres precios y, si el registrador sube el coste mayorista de .com, te enteras cuando cuadres las cuentas. La segunda es técnica: los registradores son el único tipo de adaptador donde la herencia de la clase base es obligatoria y verificada con instanceof, y donde ni el directorio oficial de extensiones te lo instala solo. Copias un fichero a mano o no hay registrador.

Este capítulo recorre los ocho adaptadores del núcleo de FOSSBilling 0.8.5, el modelo de datos real de tld y tld_registrar, cada validación que ocurre antes de que se llame a la API del registrador, la fusión del contacto del dominio con el perfil del cliente, y el esqueleto completo de un Registrar_Adapter propio con sus quince firmas abstractas —que no son las que dice la documentación oficial—. Al final tendrás un TLD vendible, un registrador configurado en modo test y un adaptador propio cargando. La versión de referencia es 0.8.5, publicada el 20 de julio de 2026, con el código auditado sobre main.

Los ocho ficheros de registrador del núcleo y las cuatro marcas blancas de ResellerClub

Todo vive bajo src/library/Registrar/. Un find sobre ese directorio devuelve exactamente esto:

src/library/Registrar/AdapterAbstract.php        248 lineas
src/library/Registrar/Domain.php                 270
src/library/Registrar/Domain/Contact.php
src/library/Registrar/Domain/Nameserver.php
src/library/Registrar/Exception.php               24
src/library/Registrar/Adapter/Custom.php         157
src/library/Registrar/Adapter/Email.php          200
src/library/Registrar/Adapter/Internetbs.php     468
src/library/Registrar/Adapter/Namecheap.php      697
src/library/Registrar/Adapter/Netearthone.php     43
src/library/Registrar/Adapter/Resellbiz.php       43
src/library/Registrar/Adapter/Resellerclub.php   895
src/library/Registrar/Adapter/Resellerid.php      43

Los tamaños ya cuentan la historia: Resellerclub.php tiene 895 líneas mientras que Netearthone.php, Resellbiz.php y Resellerid.php tienen 43 cada uno. Esos tres no son registradores distintos: son subclases finas de Registrar_Adapter_Resellerclub que solo sobreescriben __construct() y getConfig() con el atributo #[Override]. Son marcas blancas del mismo backend Directi/LogicBoxes.

AdaptadorClaseCampos de configuraciónEndpoint APIEstado real
NamecheapRegistrar_Adapter_Namecheapapi-user-id (Reseller ID, opcional), api-key (requerido), username (requerido), ip (requerido)live https://api.namecheap.com/xml.response · sandbox https://api.sandbox.namecheap.com/xml.responseFuncional. Exige whitelist de IP
Internet.bsRegistrar_Adapter_Internetbssegún getConfig()live https://api.internet.bs · test https://testapi.internet.bsFuncional
ResellerClubRegistrar_Adapter_Resellerclubuserid, api-key, ip en whitelistlive https://httpapi.com/api/ · test https://test.httpapi.com/api/Con bugs documentados
ResellerIDRegistrar_Adapter_Reselleriduserid, api-keyhereda de ResellerClubSubclase de 43 líneas
NetEarthOneRegistrar_Adapter_Netearthoneuserid, api-keyhereda de ResellerClubSubclase de 43 líneas
Resell.bizRegistrar_Adapter_Resellbizuserid, api-keyhereda de ResellerClubSubclase de 43 líneas
EmailRegistrar_Adapter_Emailemail (requerido), use_whois (radio 1/0)no aplicaNotifica por correo; registras tú
CustomRegistrar_Adapter_Customuse_whois (radio 1/0)no aplicaSiempre responde positivo

Advertencia: la documentación oficial menciona “Namecheap y Netim” como los registradores soportados, pero Netim no está en el núcleo: es una extensión externa (netim-com/fossbilling-registrar-module, versión 1.0.0), y si buscas Netim.php en src/library/Registrar/Adapter/ no lo vas a encontrar. Los campos exactos del bloque form de getConfig() en Registrar_Adapter_Internetbs no están verificados; los endpoints sí. Para conocerlos con certeza en tu instalación, sed -n '/public static function getConfig/,/^ }/p' library/Registrar/Adapter/Internetbs.php. Sirve con cualquiera de los ocho: getConfig() es la única fuente de verdad sobre qué campos pide el panel.

Registradores como extensión y el módulo EPP genérico para registros nacionales

El núcleo cubre gTLDs a través de mayoristas grandes. Para un .cl, un .mx o cualquier ccTLD que hable EPP directamente con el registro nacional necesitas algo de fuera. El directorio oficial de extensiones tiene exactamente tres registradores:

idNombreVersiónRepositorio
NetimNetim1.0.0netim-com/fossbilling-registrar-module
OpenProviderOpenProvider0.0.1Devife/fossbilling-registrar-openprovider
TPPWholesaleTPP Wholesale1.0.0grant436/fossbilling-tpp-wholesale

Puedes listarlo sin abrir el navegador con curl -sL https://extensions.fossbilling.org/api/extensions | jq '.result[] | select(.type=="domain-registrar")'.

Fuera del directorio, la pieza interesante es getnamingo/fossbilling-epp-registrar (15 estrellas, actualizado el 31 de julio de 2026), descrito por sus autores como “a generic FOSSBilling registrar module for connecting to any domain registry that uses the EPP protocol”. Es la vía práctica para registros nacionales: en lugar de escribir un adaptador por cada registro, hablas EPP con el que sea. El mismo autor mantiene getnamingo/fossbilling-dns, un módulo de DNS hosting que suele acompañarlo. La documentación oficial cita además módulos para registros basados en FRED y para el registro ucraniano, pero sin URL concreta y sin ubicación verificada: trátalos como pistas, no como opciones instalables.

flowchart TD
    START["Que TLD vas a vender"] --> Q1{"gTLD generico y volumen bajo"}
    Q1 -->|si| NC["Registrar_Adapter_Namecheap"]
    Q1 -->|no| Q2{"Eres reseller LogicBoxes o Directi"}
    Q2 -->|si| RC["Resellerclub / Resellerid / Netearthone / Resellbiz"]
    Q2 -->|no| Q3{"ccTLD nacional que habla EPP"}
    Q3 -->|si| EPP["Modulo generico EPP de getnamingo"]
    Q3 -->|no| Q4{"Registras tu a mano"}
    Q4 -->|si con aviso por correo| EM["Registrar_Adapter_Email"]
    Q4 -->|si sin aviso| CU["Registrar_Adapter_Custom con use_whois"]
    Q4 -->|no| OWN["Escribe tu propio Registrar_Adapter"]

Modelo de datos: tld, tld_registrar y service_domain

Tres tablas sostienen el módulo, y conviene tenerlas claras porque los mensajes de error hablan de columnas, no de campos de formulario. tld_registrar guarda una instancia configurada de un adaptador: id, name, registrar (el código, que es el nombre del fichero sin .php), test_mode y config (JSON con las credenciales). tld guarda una extensión vendible con sus precios y reglas. service_domain es el servicio materializado —el dominio de un cliente, con su contacto, fechas y nameservers— enlazado con client_order por service_id.

erDiagram
    tld_registrar ||--o{ tld : "abastece"
    tld ||--o{ service_domain : "clasifica"
    client_order ||--|| service_domain : "service_id"

    tld_registrar {
        int id PK
        string registrar "codigo del adaptador"
        bool test_mode
        text config "JSON de credenciales"
    }
    tld {
        string tld PK "con punto inicial"
        int tld_registrar_id FK
        float price_registration
        float price_renew
        float price_transfer
        int min_years "default 1"
        bool allow_register "default true"
        bool allow_transfer "default true"
    }
    service_domain {
        string sld
        string tld
        datetime registered_at
        datetime expires_at
        string contact_email
        string contact_first_name
    }

El módulo declara tres permisos en getModulePermissions()manage_domains, manage_tlds y manage_registrars— que puedes repartir entre roles de staff como viste en el capítulo 9. La UI vive en System → Domain registration, servida por las plantillas mod_servicedomain_tld.html.twig y mod_servicedomain_registrar.html.twig, con las rutas /admin/servicedomain, /admin/servicedomain/id/:id y /admin/servicedomain/registrar/:id.

El punto que todo el mundo malentiende: los precios por TLD se ponen a mano

“You set prices per TLD manually — FOSSBilling doesn’t pull live pricing from registrars” y “Available TLDs are defined in each registrar module.”docs.fossbilling.org/admin-guide/product-types/domains/

Esa frase está verificada contra el código: no existe ninguna tabla ni lista de TLDs con precios dentro de los adaptadores, ni en Namecheap, ni en ResellerClub, ni en ninguno. Lo que sí aparece al hacer grep de nombres de TLD en Resellerclub.php son condicionales, y confunden mucho la primera vez:

if ($tld == '.au' || $tld == '.net.au' || $tld == '.com.au') { ... }
if ($tld == '.ru' || $tld == '.com.ru' || $tld == '.org.ru' || $tld == '.net.ru') { ... }
if (in_array($tld, ['.uk', '.co.uk', '.org.uk', '.nz', '.ru', '.eu'])) { ... }
if (in_array($tld, ['.de', '.nl', '.ru', '.es', '.uk', '.co.uk', '.org.uk', '.eu', '.co'])) { ... }

Esas listas son reglas de contacto y de nameservers, no precios. Un .es exige un tipo de documento de identidad que un .com no pide, un .de impone requisitos sobre los nameservers antes de aceptar el registro, y ResellerClub codifica esas particularidades como ramas del método correspondiente: ninguna de esas líneas mueve un euro. La consecuencia operativa tiene tres partes. Uno. El margen es tuyo y es estático: pones price_registration a mano y ahí se queda hasta que lo cambies. Dos. Una subida de tarifa mayorista no te llega por ningún canal dentro de FOSSBilling; la descubres en la factura del registrador. Tres. Si vendes cincuenta extensiones, son cincuenta filas creadas a mano en tld, cada una con tres precios. Para auditar el estado actual en cualquier momento:

mysql -e "SELECT tld, tld_registrar_id, price_registration, price_renew, price_transfer, \
          min_years, allow_register, allow_transfer FROM tld" fossbilling

Alta de un TLD: precios, min_years, allow_register y allow_transfer

Campos que aceptan Servicedomain\Service::tldCreate() y tldUpdate(), tal como los normaliza el código:

CampoTipoDefaultNotas
tldstringCon punto inicial: .com, no com
tld_registrar_idintFK a tld_registrar
price_registrationfloatObligatorio al crear
price_renewfloatObligatorio al crear
price_transferfloatObligatorio al crear
min_yearsint1Se castea con (int) $data['min_years']
allow_registerbooltrueSi es false, el TLD deja de aceptar registros nuevos
allow_transferbooltrueSi es false, deja de aceptar transferencias

Los tres precios son obligatorios en la creación, sin valor por defecto ni herencia: si vas a vender .com decides hoy cuánto cobras por registrar, por renovar y por recibir una transferencia entrante. La API de administración del módulo vive en src/modules/Servicedomain/Api/Admin.php:

Método APIObligatoriosNotas
servicedomain_tld_createtld, tld_registrar_id, price_registration, price_renew, price_transferOpcionales: min_years, allow_register, allow_transfer
servicedomain_tld_updatetldResto opcional
servicedomain_tld_gettld
servicedomain_tld_get_id
servicedomain_tld_get_listAcepta paginación y filtros allow_register / allow_transfer
servicedomain_tld_deletetldFalla con 707 si hay dominios usándolo
servicedomain_registrar_get_availableAdaptadores en disco todavía no instalados
servicedomain_registrar_installcode
servicedomain_registrar_get_list / _get_pairs / _get
servicedomain_registrar_copyidDuplica una configuración
servicedomain_registrar_updateidOpcionales title y config (array)
servicedomain_registrar_deleteidDesde 0.8.1 (#3660) falla si está asignado a TLDs activos
servicedomain_batch_sync_expiration_datesSincroniza fechas de caducidad

Dar de alta cincuenta TLDs por formulario es un despropósito; con la API JSON del capítulo 14 es un bucle:

for row in ".com:12.90:13.90:12.90" ".net:15.50:16.50:15.50" ".org:14.00:15.00:14.00"; do
  IFS=: read -r tld reg ren tra <<<"$row"
  curl -s -u "TU_API_TOKEN_ADMIN:" \
    -H 'Content-Type: application/json' \
    -d "{\"tld\":\"$tld\",\"tld_registrar_id\":1,\"price_registration\":$reg,\"price_renew\":$ren,\"price_transfer\":$tra,\"min_years\":1}" \
    https://billing.example.com/api/admin/servicedomain/tld_create
done

El 1 de tld_registrar_id es el id que devuelve servicedomain_registrar_get_pairs; compruébalo antes de lanzar el bucle o crearás cincuenta TLDs apuntando a un registrador inexistente. Gotcha: allow_register y allow_transfer son tu interruptor de emergencia. Si el registrador cae o descubres que vendes .io por debajo de coste, pon allow_register a 0: el TLD sigue existiendo para los dominios ya vendidos, que se renuevan sin problema, pero deja de aceptar altas nuevas con un error 403. Es más seguro que borrar la fila, que además fallará.

Instalar y configurar un registrador, y qué hace exactamente el modo test

El descubrimiento usa Finder de Symfony sobre un único directorio, con una restricción que hay que memorizar. En registrarGetAvailable():

$finder->files()->in(Path::join(PATH_LIBRARY, 'Registrar', 'Adapter'))->name('*.php')->depth('== 0');

depth('== 0') significa solo archivos planos. A diferencia de las pasarelas de pago del capítulo 10, que admiten una subcarpeta de un nivel, aquí un Adapter/MiRegistrar/MiRegistrar.php es invisible: tiene que ser Adapter/MiRegistrar.php. La resolución de clase, en registrarGetRegistrarAdapterClassName():

$file = Path::join(PATH_LIBRARY, 'Registrar', 'Adapter', "{$model->registrar}.php");
if (!$this->filesystem->exists($file)) {
    throw new \FOSSBilling\InformationException('Domain registrar :adapter was not found', [':adapter' => $model->registrar]);
}
$class = sprintf('Registrar_Adapter_%s', $model->registrar);
if (!class_exists($class)) { require_once $file; }
if (!class_exists($class)) {
    throw new \FOSSBilling\InformationException('Registrar :adapter was not found', [':adapter' => $class]);
}
return $class;

Dos comprobaciones con dos mensajes distintos: Domain registrar :adapter was not found es “el fichero no existe”; Registrar :adapter was not found es “el fichero existe pero dentro no hay una clase Registrar_Adapter_<codigo>”, y casi siempre es un typo de mayúsculas. Y la instanciación, en registrarGetRegistrarAdapter():

$config    = $this->registrarGetConfiguration($r);      // json_decode($r->config)
$class     = $this->registrarGetRegistrarAdapterClassName($r);
$registrar = new $class($config);

if (!$registrar instanceof \Registrar_AdapterAbstract) {
    throw new \FOSSBilling\Exception('Registrar adapter :adapter should extend Registrar_AdapterAbstract', [':adapter' => $class]);
}

$registrar->setLog($this->di['logger']);
if ($order) { $registrar->setOrder($order); }
if (isset($r->test_mode) && $r->test_mode) { $registrar->enableTestMode(); }
return $registrar;

La diferencia clave con las pasarelas de pago: aquí la herencia de Registrar_AdapterAbstract es obligatoria y se verifica con instanceof. En pagos basta con que la clase tenga processTransaction(). Si escribes un registrador que no extiende la clase base, la excepción es explícita y no hay forma de saltársela. Las tres inyecciones de las últimas líneas definen todo el contrato de tiempo de ejecución:

Uno. setLog() mete el logger del contenedor. Si no lo hicieras, getLog() del adaptador crea un Box_Log con Box_LogDb('Model_ActivitySystem'): es decir, los mensajes que escribas con $this->getLog()->debug(...) acaban en la actividad del sistema en base de datos, no solo en un fichero. Es la mejor herramienta de depuración que tienes sobre un registrador en producción. Dos. setOrder($order) inyecta el Model_ClientOrder cuando la llamada nace de un pedido; queda disponible como $this->_order y es null en llamadas sueltas, como una comprobación de disponibilidad desde el carrito.

Tres. El modo test se activa por columna de base de datos, no por config global: si tld_registrar.test_mode vale 1, se llama a enableTestMode(), que pone $this->_testMode = true (por defecto false). El adaptador es responsable de leer esa propiedad y apuntar al endpoint sandbox. Si tu adaptador ignora $this->_testMode, el modo test no hace absolutamente nada, y eso es un error que se descubre registrando un dominio real por accidente.

Namecheap paso a paso: whitelist de IP, sandbox y campos exactos

Namecheap es el adaptador más completo del núcleo, con 697 líneas, y el que menos sorpresas da. Sus cuatro campos:

CampoRequeridoQué va ahí
api-user-idNoEl Reseller ID, cuando tu cuenta es de reventa
api-keyLa clave de API, que en el panel de Namecheap aparece como password
usernameTu usuario de Namecheap
ipLa IP pública del servidor que corre FOSSBilling

El campo ip no es informativo: Namecheap rechaza cualquier llamada desde una IP que no esté en su whitelist, y además el valor viaja como parámetro en la petición. Los dos tienen que coincidir con la IP real de salida de tu servidor, que averiguas desde el propio servidor con curl -s https://api.ipify.org —no la que muestra el panel de tu proveedor—. Si el VPS tiene IPv4 e IPv6 y PHP sale por IPv6, registrarás la IP equivocada y todas las llamadas fallarán con un error de autorización; lo mismo detrás de un NAT o un proxy de salida.

Los dos endpoints que el adaptador elige según $this->_testMode son https://api.namecheap.com/xml.response en producción y https://api.sandbox.namecheap.com/xml.response en sandbox.

Gotcha: la cuenta de sandbox de Namecheap es una cuenta distinta, con sus propias credenciales y su propia whitelist de IP. No puedes marcar test_mode y esperar que tus credenciales de producción funcionen contra el sandbox. El flujo correcto es crear dos filas en tld_registrar —una con test_mode = 1 y credenciales de sandbox, otra con test_mode = 0 y credenciales reales— y mover el tld_registrar_id de tus TLDs de una a otra cuando pases a producción. Para eso existe servicedomain_registrar_copy: duplica la configuración y solo cambias lo que difiere.

ResellerClub y sus tres subclases: qué comparten y qué bugs arrastran

Registrar_Adapter_Resellerclub es el adaptador más grande del núcleo con 895 líneas y el que más advertencias lleva encima. La documentación oficial dice literalmente que requiere arreglos antes de poder usarse con fiabilidad, lo que significa que vas a tener que leer el código y probar cada operación en el entorno de test antes de vender nada.

Las tres subclases son idénticas en estructura: 43 líneas y dos métodos sobreescritos.

class Registrar_Adapter_Resellerid extends Registrar_Adapter_Resellerclub
{
    #[Override]
    public function __construct($options) { /* credenciales propias */ }

    #[Override]
    public static function getConfig() { /* etiquetas propias */ }
}

Todo lo demás —registerDomain(), transferDomain(), modifyNs(), las reglas por TLD, el manejo de errores— es el mismo código heredado. Esto tiene dos lecturas. La buena: si arreglas un bug en Resellerclub.php lo arreglas para las cuatro marcas. La mala: cualquier bug de ResellerClub lo tienen también ResellerID, NetEarthOne y Resell.biz, y cambiar de marca blanca no lo esquiva.

Los endpoints compartidos son https://httpapi.com/api/ en producción y https://test.httpapi.com/api/ en test. Igual que en Namecheap, la IP del servidor tiene que estar en la whitelist del panel y el entorno de test tiene credenciales propias. Los condicionales por TLD que verás dentro son, insisto, reglas de negocio del registro: .au requiere identificadores de elegibilidad, .ru impone un formato de contacto concreto, y las listas que incluyen .uk, .eu, .de, .nl, .es o .co controlan cómo se envían los nameservers y los contactos. Ninguna define precios. Si estás depurando por qué un .es falla y un .com funciona con el mismo registrador, ahí es donde tienes que mirar.

Los adaptadores Custom y Email: registro manual, avisos y WHOIS real

Estos dos se malinterpretan por su nombre. No hablan con ninguna API de registrador: sirven para vender dominios cuando el registro lo haces tú a mano.

Registrar_Adapter_Email (200 líneas) tiene dos campos: email (requerido) y use_whois (un radio 1/0). Cuando el sistema necesita registrar, transferir, renovar o modificar un dominio, te manda un correo a esa dirección describiendo lo que hay que hacer; tú entras al panel del registrador real y lo ejecutas, mientras FOSSBilling se comporta como si la operación hubiera tenido éxito y guarda las fechas. El detalle que rompe instalaciones: sin use_whois, isDomainAvailable() lanza una excepción, así que el botón de comprobar disponibilidad del carrito deja de funcionar para todos los TLDs de ese registrador.

Registrar_Adapter_Custom (157 líneas) es todavía más simple: siempre responde positivo, sin correo y sin API. Existe para dos casos: llevar en FOSSBilling la contabilidad de dominios que gestionas por fuera, y probar el flujo completo de pedido a activación sin tocar un registrador. Su único campo es use_whois.

Con use_whois = 1, ambos hacen una consulta WHOIS real con Iodev\Whois\Factory, la librería io-developer/php-whois (^4.1.10) que FOSSBilling trae en su composer.json. No es una simulación: sale a internet y pregunta al servidor WHOIS del TLD.

Advertencia: WHOIS tiene límites de tasa agresivos por IP. Si pones tu tienda al público con use_whois activo y alguien teclea deprisa en el buscador, el servidor WHOIS del TLD te bloqueará temporalmente y las comprobaciones fallarán de forma intermitente. Es válido para operación manual de bajo volumen, no para un buscador con tráfico.

CustomEmail
Líneas157200
Camposuse_whoisemail (req.), use_whois
Avisa de las operacionesNoSí, por correo a la dirección configurada
isDomainAvailable() sin WHOISDevuelve positivoLanza excepción
isDomainAvailable() con WHOISConsulta realConsulta real
Caso de usoContabilidad interna y pruebasReventa manual con aviso

El formulario de pedido: register, transfer, owndomain y subdomain

Cuando el cliente añade un dominio al carrito, el módulo lee un campo de acción y bifurca. _getTuple() de Servicedomain\Service acepta cuatro valores:

AcciónQué significaQué se le cobra
registerRegistro nuevoprice_registration
transferTransferencia entrante desde otro registradorprice_transfer
owndomainEl cliente ya tiene el dominio y solo quiere el hostingNada por el dominio
subdomainSubdominio de un dominio tuyo, en productos de hostingNada

owndomain es el que se olvida al diseñar la tienda: un cliente que ya tiene su .com en otro sitio y solo compra hosting no debe pasar por el registrador. subdomain es su equivalente para el subdominio gratuito que 0.8.1 añadió a los productos de hosting (#3667), y los TLDs disponibles se listan por el endpoint invitado guest/servicehosting/free_tlds. El área pública expone además cuatro endpoints sin autenticación, que son los que consume el buscador de dominios del tema: guest/servicedomain/tlds (extensiones vendibles), guest/servicedomain/pricing (precios por TLD), guest/servicedomain/check (disponibilidad) y guest/servicedomain/can_be_transferred.

Gotcha: check y can_be_transferred acaban llamando a la API del registrador. Son endpoints públicos que consumen cuota de un tercero, así que revisa la configuración de rate limiting que viste en el capítulo 3 antes de exponer un buscador de dominios en la portada.

Validaciones antes de llamar al registrador: isSldValid, allow_register y min_years

FOSSBilling filtra bastante antes de gastar una llamada de API. Este es el orden exacto de isDomainAvailable() en Servicedomain\Service.php:

if (empty($sld)) {
    throw new \FOSSBilling\InformationException('Domain name is invalid');
}
$validator = $this->di['validator'];
if (!$validator->isSldValid($sld)) {
    $safe_dom = htmlspecialchars((string) $sld, ENT_QUOTES | ENT_HTML5, 'UTF-8');
    throw new \FOSSBilling\InformationException('Domain name :domain is invalid', [':domain' => $safe_dom]);
}
if (!$model->allow_register) {
    throw new \FOSSBilling\InformationException('Domain cannot be registered', null, 403);
}
$domain = new \Registrar_Domain();
$domain->setTld($model->tld);
$domain->setSld($sld);
$tldRegistrar = $this->di['db']->load('TldRegistrar', $model->tld_registrar_id);
$adapter = $this->registrarGetRegistrarAdapter($tldRegistrar);

return $adapter->isDomainAvailable($domain);

Cuatro cosas a señalar. Uno. El SLD vacío se corta primero. Dos. isSldValid() es el validador del contenedor, y cuando falla el dominio se escapa con htmlspecialchars() antes de meterlo en el mensaje: es una defensa contra XSS reflejado a través del buscador de dominios, porque ese mensaje se pinta en la página. Tres. allow_register a 0 produce un 403 con el texto Domain cannot be registered, y ocurre antes de instanciar el adaptador: cero llamadas de API. Cuatro. Solo entonces se construye el Registrar_Domain y se resuelve el adaptador.

canBeTransferred() es el método análogo: misma secuencia, pero comprobando allow_transfer y terminando en isDomaincanBeTransferred(). El periodo mínimo se valida aparte:

if ($years < $tld->min_years) {
    throw new \FOSSBilling\Exception(':tld can be registered for at least :years years', ...);
}

Si vendes un .com.au que exige dos años y tu fila de tld tiene min_years = 1, el registrador rechazará el registro después de que el cliente haya pagado y tendrás un pedido en failed_setup.

Construcción del contacto: la fusión con el perfil del cliente

El contacto que se envía al registrador no sale solo de service_domain: _getD() hace una fusión campo a campo en la que cada valor vacío de la fila del dominio cae al equivalente del Client.

$email      = empty($model->contact_email)      ? $client->email      : $model->contact_email;
$first_name = empty($model->contact_first_name) ? $client->first_name : $model->contact_first_name;
// idem para city, postcode, country, state, phone, phone_cc, company, address_1, address_2

$contact = new \Registrar_Domain_Contact();
$contact->setEmail($email)->setUsername($email)
        ->setPassword($this->di['tools']->generatePassword(10))
        ->setFirstname($first_name)->setLastname($last_name)
        ->setCity($city)->setZip($zip)->setCountry($country)->setState($state)
        ->setTel($phone)->setTelCC($phone_cc)
        ->setCompany($company)->setCompanyNumber($company_number)
        ->setAddress1($address1)->setAddress2($address2)->setFax($phone);

Los campos que participan en la fusión son doce: email, first_name, last_name, city, postcode, country, state, phone, phone_cc, company, address_1 y address_2. Además, _getD() aporta dos cosas que no vienen del formulario del dominio —birthday y el número de documento vía resolveDocumentNumber($client), necesarios para los TLDs con requisitos de identidad, los mismos que disparan los condicionales de ResellerClub— y genera una contraseña con generatePassword(10) para los registradores que piden credencial de contacto.

Advertencia: esto convierte el perfil del cliente en datos de WHOIS. Si un cliente tiene el teléfono mal o el país vacío en su ficha —y en el capítulo 6 viste que los campos obligatorios del registro son configurables—, ese error viaja tal cual al registro del dominio. Registros como .eu o .es lo rechazan por contacto incompleto, y el fallo aparece en la activación con la factura ya pagada: si vendes ccTLDs, haz obligatorios en el registro de clientes los campos que esos TLDs exigen. El objeto que se construye alrededor del contacto es Registrar_Domain, con estos accesores: RegistrationPeriod en años, ExpirationTime y RegistrationTime; Name compuesto de sld+tld, Tld con getTld($with_dot = true) y Sld; Ns1 a Ns4; Epp, PrivacyEnabled y Locked como booleanos; y los cuatro contactos ContactRegistrar, ContactAdmin, ContactTech y ContactBilling. Y Registrar_Domain_Contact acepta: Id, Name, FirstName, LastName, Email, City, Country, State, Zip, Tel, TelCc, Fax, FaxCc, Company, CompanyNumber, Address1, Address2, Address3, Username, Password, DocumentType, DocumentNr, JobTitle, Birthday e IdnLanguageCode. Que existan cuatro contactos separados importa: muchos registros exigen registrant, admin, tech y billing diferenciados, y tu adaptador tiene que mapearlos aunque FOSSBilling los rellene con los mismos datos.

Registro y transferencia: registerDomain, EPP y guardado de fechas

Con las validaciones pasadas y la factura pagada, el ciclo de vida del pedido del capítulo 7 llama a action_create() y luego a action_activate() del módulo Servicedomain.

sequenceDiagram
    participant C as Cliente
    participant FB as FOSSBilling Servicedomain
    participant R as API del registrador

    C->>FB: Formulario de pedido register / transfer / owndomain / subdomain
    FB->>FB: _getTuple devuelve sld y tld
    FB->>FB: Model_Tld comprueba allow_register y min_years
    FB->>FB: validator isSldValid sobre el sld
    FB->>R: adapter isDomainAvailable con Registrar_Domain
    R-->>FB: disponible o no
    C->>FB: Confirma el pedido y se emite la factura
    Note over FB: El pago se acredita
    FB->>FB: action_create crea la fila service_domain
    FB->>FB: action_activate monta Registrar_Domain mas Contact con _getD
    FB->>R: adapter registerDomain
    R-->>FB: exito
    FB->>FB: Guarda registered_at y expires_at
    FB->>C: Email mod_servicedomain_activated

Dos puntos del diagrama merecen subrayado. El primero: la disponibilidad se comprueba en el carrito, no en la activación. Entre que el cliente consulta y el pago se acredita pueden pasar horas, y el dominio puede haber sido registrado por otro; en ese caso registerDomain() falla, el pedido queda en failed_setup con el mensaje del registrador en el historial y la factura ya está pagada. No hay reintento automático: se atiende a mano reactivando el pedido una vez el cliente elija otro nombre.

El segundo: en una transferencia, el código EPP es responsabilidad del cliente. El campo Epp de Registrar_Domain se rellena con el código de autorización que el cliente obtuvo en su registrador anterior; transferDomain() lo envía y el proceso queda en manos de los dos registradores, que pueden tardar días. FOSSBilling marca el pedido como activo cuando la petición se acepta, no cuando la transferencia termina, y esa diferencia conviene explicarla en la ficha del producto. Tras el éxito se guardan registered_at y expires_at en service_domain, y el listener de activación envía la plantilla mod_servicedomain_activated.

Nota de versión: 0.8.4 corrigió que los dominios pagados con crédito disparaban una doble activación (#3948); por debajo de esa versión, aceptar pagos con saldo te dará dominios registrados dos veces.

Renovación: por qué el precio se recalcula y no se hereda del pedido

Para todos los productos de FOSSBilling, la renovación respeta el precio guardado en el pedido. Eso es lo que permite los precios grandfathered: subes la tarifa de un plan de hosting y los clientes antiguos siguen pagando lo de siempre porque su client_order conserva su importe. Los dominios no. En Invoice\Service::generateForOrder(), la llamada a getProductRenewalLineConfig() recalcula el precio de renovación desde la configuración del TLD en cada ciclo, en lugar de heredar el del pedido.

Producto normalDominio
Origen del precio de renovaciónEl guardado en client_orderRecalculado desde la config del TLD
¿Admite precio grandfathered?No
Efecto de subir el precio en el catálogoSolo afecta a pedidos nuevosAfecta a todas las renovaciones futuras

La lógica es defendible —el coste mayorista de un TLD cambia y facturar renovaciones a un precio congelado de hace tres años es perder dinero por dominio— pero tiene dos consecuencias prácticas. Uno. Cambiar price_renew de una fila de tld cambia lo que van a pagar todos tus clientes existentes en su próxima renovación: no es un cambio de catálogo, es un cambio de tarifa retroactivo hacia adelante. Dos. Si quieres un precio especial para un cliente concreto, editar el importe de su pedido no sirve: el siguiente ciclo lo vuelve a poner al del TLD. La única vía dentro del sistema es un TLD duplicado con otro registrador asignado, o un descuento gestionado por fuera con notas de crédito, como viste en el capítulo 8.

Acciones del cliente y sincronización: nameservers, EPP, lock, privacidad y batch_sync_expiration_dates

Con el dominio activo, el área de cliente expone las operaciones que el adaptador implementa; cada acción se traduce en una llamada sobre Registrar_Domain:

Acción del clienteMétodo del adaptadorQué se rellena antes
Cambiar nameserversmodifyNs()Ns1 a Ns4 del Registrar_Domain
Actualizar datos de contactomodifyContact()Los cuatro contactos vía _getD()
Obtener el código EPPgetEpp()Devuelve un string
Bloquear / desbloquearlock() / unlock()
Activar / desactivar privacidadenablePrivacyProtection() / disablePrivacyProtection()
Ver el estado realgetDomainDetails()Devuelve un Registrar_Domain relleno

El bloqueo de transferencia (lock) y la privacidad de WHOIS son las que más soporte generan, porque su estado real vive en el registrador y no en tu base de datos. getDomainDetails() es el método que las reconcilia: devuelve un Registrar_Domain con Locked, PrivacyEnabled, las fechas y los nameservers tal como los ve el registrador.

Para las fechas hay una tarea de lote, servicedomain_batch_sync_expiration_dates, que recorre los dominios y actualiza expires_at desde el registrador. Es imprescindible en dos escenarios: cuando has migrado dominios desde otro sistema con fechas dudosas, y cuando alguien ha renovado un dominio directamente en el panel del registrador saltándose FOSSBilling. Sin ella tu sistema seguirá facturando una renovación ya pagada, o suspenderá un dominio que sigue vivo.

curl -s -u "TU_API_TOKEN_ADMIN:" \
  -H 'Content-Type: application/json' -d '{}' \
  https://billing.example.com/api/admin/servicedomain/batch_sync_expiration_dates

No está en la lista de tareas del cron interno de 0.8.5, así que para ejecutarlo periódicamente engánchalo desde fuera con un cron propio o desde un hook, según lo que verás en el capítulo 14.

Las quince firmas abstractas reales de Registrar_AdapterAbstract frente a la documentación

Aquí es donde la documentación oficial hace más daño. src/library/Registrar/AdapterAbstract.php declara quince firmas abstractas, contando la estática:

abstract public static function getConfig();
abstract public function isDomainAvailable(Registrar_Domain $domain);
abstract public function isDomaincanBeTransferred(Registrar_Domain $domain);   // ojo a la 'c' minuscula
abstract public function modifyNs(Registrar_Domain $domain);
abstract public function modifyContact(Registrar_Domain $domain);
abstract public function transferDomain(Registrar_Domain $domain);
abstract public function getDomainDetails(Registrar_Domain $domain);           // devuelve Registrar_Domain
abstract public function getEpp(Registrar_Domain $domain);                     // devuelve string
abstract public function registerDomain(Registrar_Domain $domain);
abstract public function renewDomain(Registrar_Domain $domain);
abstract public function deleteDomain(Registrar_Domain $domain);
abstract public function enablePrivacyProtection(Registrar_Domain $domain);
abstract public function disablePrivacyProtection(Registrar_Domain $domain);
abstract public function lock(Registrar_Domain $domain);
abstract public function unlock(Registrar_Domain $domain);

isDomaincanBeTransferred lleva la c en minúscula. No es una errata de este capítulo: es el nombre real del método. Si escribes isDomainCanBeTransferred, PHP te dirá que tu clase sigue siendo abstracta y no podrás instanciarla. Y ahora la comparación con la guía oficial de creación de integraciones:

Lo que documenta la guía oficialLo que existe en el código
isAvailable()No existe. Es isDomainAvailable()
register()No existe. Es registerDomain()
transfer()No existe. Es transferDomain()
renew()No existe. Es renewDomain()
getInfo()No existe. Es getDomainDetails()
modifyNs()Existe
modifyContact()Existe
Faltan en la doc: isDomaincanBeTransferred(), getEpp(), deleteDomain(), enablePrivacyProtection(), disablePrivacyProtection(), lock(), unlock(), getConfig()

De siete métodos documentados, cinco no existen y faltan ocho. Si sigues la documentación al pie de la letra, tu clase no compila. La verificación en tu instalación es de una línea: grep -n "abstract public" library/Registrar/AdapterAbstract.php.

Los métodos no abstractos que heredas y usas sin implementar son setLog(Box_Log $log), getLog(), getHttpClient(), enableTestMode() y setOrder(Model_ClientOrder $order): static; las propiedades protegidas, $_log, $_testMode (bool, false por defecto) y $_order (?Model_ClientOrder).

flowchart LR
    SVC["Servicedomain Service"] --> GET["registrarGetRegistrarAdapter"]
    GET --> NEW["new Registrar_Adapter_X con config JSON"]
    NEW --> INST{"instanceof Registrar_AdapterAbstract"}
    INST -->|no| ERR["Excepcion: should extend Registrar_AdapterAbstract"]
    INST -->|si| INJ["setLog mas setOrder mas enableTestMode"]
    INJ --> OPS["isDomainAvailable / registerDomain / transferDomain / renewDomain"]
    INJ --> OPS2["modifyNs / modifyContact / getEpp / lock / privacidad"]
    OPS --> HTTP["getHttpClient de Symfony"]
    OPS2 --> HTTP
    HTTP --> API["API del registrador"]
    INJ --> LOG["getLog escribe en Model_ActivitySystem"]

Esqueleto de un Registrar_Adapter propio con Symfony HttpClient

Este adaptador es completo y compilable: implementa las quince firmas, usa getHttpClient() —el cliente de Symfony HttpClient que configura el núcleo— y respeta $this->_testMode para elegir endpoint.

<?php

declare(strict_types=1);

class Registrar_Adapter_MiRegistrar extends Registrar_AdapterAbstract
{
    public $config = ['api_key' => null, 'api_secret' => null, 'ip' => null];

    public function __construct($options)
    {
        foreach (['api_key' => 'API Key', 'api_secret' => 'API Secret'] as $key => $label) {
            if (empty($options[$key])) {
                throw new Registrar_Exception(
                    'The ":domain_registrar" domain registrar is not fully configured. Please configure the :missing',
                    [':domain_registrar' => 'MiRegistrar', ':missing' => $label],
                    3001
                );
            }
            $this->config[$key] = $options[$key];
        }
        $this->config['ip'] = $options['ip'] ?? null;
    }

    public static function getConfig(): array
    {
        return [
            'label' => 'Gestiona dominios en MiRegistrar via API. Requiere autorizar la IP del servidor.',
            'form' => [
                'api_key'    => ['text',     ['label' => 'API Key',    'description' => 'Panel -> Settings -> API', 'required' => true]],
                'api_secret' => ['password', ['label' => 'API Secret', 'description' => 'Se muestra una sola vez.',  'required' => true]],
                'ip'         => ['text',     ['label' => 'IP del servidor', 'description' => 'Debe estar en la whitelist.', 'required' => false]],
            ],
        ];
    }

    public function isDomainAvailable(Registrar_Domain $domain): bool
    {
        $this->getLog()->debug('Checking domain availability: ' . $domain->getName());
        $r = $this->request('GET', '/v1/availability', ['domain' => $domain->getName()]);
        return (bool) ($r['available'] ?? false);
    }

    public function isDomaincanBeTransferred(Registrar_Domain $domain): bool
    {
        $r = $this->request('GET', '/v1/transfer/check', ['domain' => $domain->getName()]);
        return (bool) ($r['transferable'] ?? false);
    }

    public function getDomainDetails(Registrar_Domain $domain): Registrar_Domain
    {
        $r = $this->request('GET', $this->path($domain));
        $domain->setRegistrationTime(strtotime((string) $r['created_at']));
        $domain->setExpirationTime(strtotime((string) $r['expires_at']));
        $domain->setLocked((bool) ($r['locked'] ?? false));
        $domain->setPrivacyEnabled((bool) ($r['privacy'] ?? false));
        if (!empty($r['ns'][0])) { $domain->setNs1($r['ns'][0]); }
        if (!empty($r['ns'][1])) { $domain->setNs2($r['ns'][1]); }
        return $domain;
    }

    public function getEpp(Registrar_Domain $domain): string
    {
        $r = $this->request('GET', $this->path($domain) . '/auth-code');
        return (string) ($r['auth_code'] ?? '');
    }

    public function registerDomain(Registrar_Domain $domain): bool
    {
        $this->getLog()->debug('Registering domain: ' . $domain->getName()
            . ' for ' . $domain->getRegistrationPeriod() . ' years');
        $c = $domain->getContactRegistrar();
        $this->request('POST', '/v1/domains', [
            'domain'  => $domain->getName(),
            'years'   => $domain->getRegistrationPeriod(),
            'ns'      => $this->nameservers($domain),
            'privacy' => $domain->getPrivacyEnabled() ? 1 : 0,
            'contact' => [
                'first_name' => $c->getFirstName(), 'last_name' => $c->getLastName(),
                'email'      => $c->getEmail(),     'company'   => $c->getCompany(),
                'address1'   => $c->getAddress1(),  'city'      => $c->getCity(),
                'state'      => $c->getState(),     'zip'       => $c->getZip(),
                'country'    => $c->getCountry(),
                'phone_cc'   => $c->getTelCc(),     'phone'     => $c->getTel(),
            ],
        ]);
        return true;
    }

    public function transferDomain(Registrar_Domain $domain): bool
    {
        $this->getLog()->debug('Transfering domain: ' . $domain->getName());
        $this->request('POST', '/v1/transfers',
            ['domain' => $domain->getName(), 'auth_code' => $domain->getEpp()]);
        return true;
    }

    public function renewDomain(Registrar_Domain $domain): bool
    {
        $this->request('POST', $this->path($domain) . '/renew',
            ['years' => $domain->getRegistrationPeriod() ?: 1]);
        return true;
    }

    public function deleteDomain(Registrar_Domain $domain): bool
    {
        $this->request('DELETE', $this->path($domain));
        return true;
    }

    public function modifyNs(Registrar_Domain $domain): bool
    {
        $this->request('PUT', $this->path($domain) . '/nameservers', ['ns' => $this->nameservers($domain)]);
        return true;
    }

    public function modifyContact(Registrar_Domain $domain): bool
    {
        $c = $domain->getContactRegistrar();
        $this->request('PUT', $this->path($domain) . '/contact', [
            'email' => $c->getEmail(), 'first_name' => $c->getFirstName(), 'last_name' => $c->getLastName(),
        ]);
        return true;
    }

    public function enablePrivacyProtection(Registrar_Domain $domain): bool
    {
        $this->request('POST', $this->path($domain) . '/privacy', ['enabled' => true]);
        return true;
    }

    public function disablePrivacyProtection(Registrar_Domain $domain): bool
    {
        $this->request('POST', $this->path($domain) . '/privacy', ['enabled' => false]);
        return true;
    }

    public function lock(Registrar_Domain $domain): bool
    {
        $this->request('POST', $this->path($domain) . '/lock');
        return true;
    }

    public function unlock(Registrar_Domain $domain): bool
    {
        $this->request('POST', $this->path($domain) . '/unlock');
        return true;
    }

    private function path(Registrar_Domain $domain): string
    {
        return '/v1/domains/' . rawurlencode($domain->getName());
    }

    private function nameservers(Registrar_Domain $domain): array
    {
        return array_values(array_filter([
            $domain->getNs1(), $domain->getNs2(), $domain->getNs3(), $domain->getNs4(),
        ]));
    }

    /** El modo test lo activa enableTestMode desde registrarGetRegistrarAdapter. */
    private function baseUrl(): string
    {
        return $this->_testMode
            ? 'https://sandbox.miregistrar.example'
            : 'https://api.miregistrar.example';
    }

    private function request(string $method, string $path, array $params = []): array
    {
        $client = $this->getHttpClient()->withOptions([
            'timeout' => 60,
            'headers' => [
                'X-Api-Key'    => $this->config['api_key'],
                'X-Api-Secret' => $this->config['api_secret'],
            ],
        ]);

        try {
            $options  = ($method === 'GET') ? ['query' => $params] : ['json' => $params];
            $response = $client->request($method, $this->baseUrl() . $path, $options);
            $data     = json_decode($response->getContent(), true);
        } catch (\Throwable $e) {
            $this->getLog()->error('MiRegistrar API error: ' . $e->getMessage());
            throw new Registrar_Exception('MiRegistrar API error: :error', [':error' => $e->getMessage()]);
        }

        if (!is_array($data)) {
            throw new Registrar_Exception('MiRegistrar devolvio una respuesta no valida');
        }
        if (!empty($data['error'])) {
            throw new Registrar_Exception('MiRegistrar: :error', [':error' => (string) $data['error']]);
        }
        return $data;
    }
}

Cinco decisiones de diseño de ese código merecen explicación. Uno. El constructor valida las credenciales y lanza Registrar_Exception con código 3001 y el mensaje The ":domain_registrar" domain registrar is not fully configured. Please configure the :missing; es la convención del núcleo, igual que 2001 para server managers y 4001 para pasarelas de pago. Dos. getConfig() devuelve un array con label y form, y cada campo es [tipo, [opciones]] con los tipos text, password o radio; eso es lo que el panel renderiza en System → Domain registration.

Tres. getHttpClient() viene de la clase base y devuelve un cliente Symfony: no uses curl a pelo, porque el del núcleo respeta las opciones globales de red. Cuatro. Todo error se envuelve en Registrar_Exception; si dejas escapar una excepción de HttpClient, el administrador ve un mensaje ilegible y el pedido queda en failed_setup sin pista útil. Cinco. Los getLog()->debug() no son decorativos: acaban en la actividad del sistema en base de datos y son tu traza de auditoría de lo que se le pidió al registrador y cuándo.

Instalación manual, el bug del autoloader de 0.8.4 y depuración de errores 3001, 403 y 707

Los registradores no son auto-instalables. El tipo domain-registrar existe en FOSSBilling\ExtensionManager como TYPE_DR, pero no aparece en getExtensionBasePath(), el método que decide qué tipos instala el sistema desde el directorio. Por eso el botón de instalar del catálogo no funciona con registradores, igual que no funciona con server managers: solo mod, theme, translation y payment-gateway se instalan solos. El procedimiento manual completo:

# Copiar el fichero PLANO, sin subcarpeta, y darle el propietario del servidor web
cp MiRegistrar.php /ruta/fossbilling/library/Registrar/Adapter/MiRegistrar.php
chown www-data:www-data /ruta/fossbilling/library/Registrar/Adapter/MiRegistrar.php
rm -rf /ruta/fossbilling/data/cache/*

# Verificar que el sistema lo ve entre los disponibles
curl -s -u "TU_API_TOKEN_ADMIN:" -H 'Content-Type: application/json' -d '{}' \
  https://billing.example.com/api/admin/servicedomain/registrar_get_available

Después, en System → Domain registration → New registrar, eliges MiRegistrar, rellenas las credenciales y decides test_mode. Las tres reglas que hacen fallar la instalación, en orden de frecuencia: el fichero tiene que ser plano (depth('== 0')), el nombre del fichero es el código del registrador, y la clase tiene que llamarse Registrar_Adapter_<codigo> con las mismas mayúsculas que el fichero.

El bug del autoloader. Hasta 0.8.3 inclusive, los adaptadores de registrador de terceros no se cargaban después de instalarlos. La causa, según las notas de la release 0.8.4: “third-party registrar adapters were not being loaded after installation due to Composer’s optimized autoloader not discovering the files”. El autoloader optimizado construye un classmap en tiempo de instalación y un fichero copiado después no está en él; 0.8.4 (#3832) lo corrigió con un require_once explícito con whitelist, el if (!class_exists($class)) require_once $file; que viste antes. Si estás en 0.8.3 o anterior y tu adaptador no aparece, el rodeo es regenerar el classmap con sudo -u www-data composer dump-autoload -o desde la raíz de la instalación; la solución de verdad es actualizar a 0.8.4 o superior.

Nota de arquitectura: el issue abierto #4121 propone mover pasarelas, registradores y server managers desde src/library/ a src/extensions/. Si se implementa, todas las rutas de este capítulo cambian. Antes de automatizar despliegues que copien ficheros a library/Registrar/Adapter/, comprueba el estado de ese issue para tu versión.

Errores comunes y diagnóstico

SíntomaCausa probableDiagnósticoSolución
Domain registrar :adapter was not foundEl fichero no existe o está en una subcarpetals -l library/Registrar/Adapter/Copiar el .php plano, sin subdirectorio
Registrar :adapter was not foundEl fichero existe pero la clase no se llama Registrar_Adapter_<codigo>grep -n "^class" library/Registrar/Adapter/MiRegistrar.phpAlinear nombre de clase, nombre de fichero y mayúsculas
Registrar adapter :adapter should extend Registrar_AdapterAbstractLa clase no hereda de la baseRevisar la declaración extendsEn registradores la herencia es obligatoria, a diferencia de las pasarelas
Error 3001 al guardar el registradorFalta una credencial requeridaEl mensaje trae el campo en :missingRellenar el campo en System → Domain registration
Error 403 Domain cannot be registeredallow_register = 0 en esa fila de tldSELECT tld, allow_register FROM tldPoner allow_register = 1 o dejarlo apagado a propósito
Error 707 TLD is used by :count: domainsIntentas borrar un TLD con dominios activosEl propio mensaje da el recuentoNo borrar: apagar allow_register y allow_transfer
No se puede borrar un registradorEstá asignado a TLDs activos, bloqueado desde 0.8.1 (#3660)SELECT tld FROM tld WHERE tld_registrar_id = NReasignar esos TLDs a otro registrador primero
:tld can be registered for at least :years yearsmin_years del TLD mayor que el periodo pedidoSELECT tld, min_years FROM tldAjustar min_years al mínimo real del registro
El adaptador nuevo no aparece en el desplegableCaché de Twig, o el bug del autoloader anterior a 0.8.4registrar_get_available por APIrm -rf data/cache/*; si sigue, actualizar a 0.8.4+
Todas las llamadas fallan por autorizaciónLa IP de salida no está en la whitelist del registradorcurl -s https://api.ipify.org desde el servidorAñadir esa IP exacta en el panel del registrador y en el campo ip
test_mode activado pero registra de verdadEl adaptador ignora $this->_testModeBuscar _testMode en el fichero del adaptadorImplementar la selección de endpoint por modo
Credenciales de producción fallan en sandboxSandbox es una cuenta separadaProbar las credenciales contra el endpoint sandboxCrear una segunda fila tld_registrar con registrar_copy
El botón de comprobar dominio no respondeRegistrador Email sin use_whoisEl adaptador lanza excepción en isDomainAvailable()Activar use_whois = 1
Comprobaciones WHOIS intermitentesLímite de tasa del servidor WHOIS del TLDFallos que aparecen y desaparecen sin patrónBajar el volumen o usar un registrador con API real
Pedido en failed_setup tras pagarEl dominio se registró entre la consulta y el pago, o el contacto está incompletoHistorial del pedido en el panel; actividad del sistemaCorregir el contacto y reactivar, o cambiar el nombre con el cliente
Un .es o .eu falla y un .com funcionaRequisitos de identidad del ccTLD sin cubrirBuscar el TLD en los condicionales del adaptadorRellenar documento y birthday en la ficha del cliente
Renovaciones facturadas al precio nuevo sin avisarEl precio de renovación se recalcula por diseñoComparar price_renew con el importe del pedidoEs el comportamiento esperado; avisa antes de tocar price_renew
Se factura una renovación ya pagada en el registradorexpires_at desincronizadoSELECT sld, tld, expires_at FROM service_domainEjecutar servicedomain_batch_sync_expiration_dates
Dominio registrado dos veces al pagar con saldoDoble activación, corregida en 0.8.4 (#3948)Dos entradas de activación en el historialActualizar a 0.8.4 o superior
Un bug de ResellerClub persiste al cambiar a Resell.bizLas tres subclases heredan el mismo códigowc -l library/Registrar/Adapter/Resellbiz.php devuelve 43Corregir en Resellerclub.php o cambiar de familia de registrador

Lo que queda operativo

Tienes el módulo de dominios mapeado contra el código de 0.8.5, no contra la documentación: sabes que los ocho ficheros del núcleo son en realidad cinco integraciones más tres marcas blancas de ResellerClub, que Netim no está en el núcleo aunque la doc lo diga, y que para ccTLDs nacionales la vía razonable es el módulo genérico EPP de getnamingo. Tienes claro también el hecho que define la operación económica: los precios por TLD son manuales, los tres son obligatorios al crear la fila, y el de renovación es el único de todo FOSSBilling que se recalcula en cada ciclo en lugar de heredarse del pedido. Sabes que allow_register es tu interruptor de emergencia y que borrar un TLD en uso falla con 707.

Del lado técnico tienes la cadena completa: Finder con depth('== 0'), la verificación instanceof que aquí sí es obligatoria, las tres inyecciones de setLog, setOrder y enableTestMode, la fusión del contacto con el perfil del cliente en _getD(), y las quince firmas abstractas reales —con la c minúscula de isDomaincanBeTransferred— que la guía oficial documenta mal en cinco de siete. Y un adaptador propio que compila, instala y se depura.

Con pagos, hosting y dominios integrados, has agotado lo que se puede hacer con los adaptadores que el núcleo ya prevé. El siguiente paso es salirse de esos tres huecos: en el capítulo 13 entras en la arquitectura interna de FOSSBilling —el contenedor Pimple, los modelos RedBean, el enrutado de módulos y el manifest.json— para escribir un módulo completo con su propio tipo de servicio, su API y sus pantallas de administración.