Blog ·
Quince cosas que una migración de facturación no puede llevarse
Una migración de facturación no es una copia de datos. Quince omisiones medidas, el dinero como céntimos enteros y la huella como única clave útil.
Por Provibr
Trasladar un negocio de hosting de un panel de facturación a otro parece una copia de datos y no lo es. Dos paneles que saben ambos qué es un cliente, un servicio y una factura siguen sin ponerse de acuerdo sobre qué contienen esos objetos, y el desacuerdo nunca es simétrico: el origen suele saber más de lo que la API del destino está dispuesta a aceptar. Una migración que finge lo contrario escribe documentos que no coinciden con los que el cliente ya recibió.
Por eso lo primero que produce esta herramienta no son datos. Es una lista de lo que no va a llegar.
El dinero son céntimos enteros, y la conversión lee texto
Ambos paneles entregan un importe como número JSON (9.95) o como texto decimal ("9.95"). La conversión evidente es multiplicar por cien.
La conversión evidente está mal. Como número de coma flotante de 64 bits, 9,95 equivale a 9,949999999999999289, y 9.95 * 100.0 da 994,9999999999999. Truncado son 994 céntimos: un céntimo menos de lo que dice la factura. En un servidor de juego eso es una molestia de redondeo. En una contabilidad es un documento incorrecto, y aparece cuando alguien cierra el ejercicio.
Por eso el modelo canónico lleva un importe como número entero de céntimos más un código de moneda, y la conversión cuenta cifras en la forma textual en lugar de pasar por una coma flotante. Más de dos decimales se redondean alejándose de cero en vez de truncarse, porque medio céntimo truncado a nada hace desaparecer una línea de factura. La notación científica se rechaza sin más: admitir 1e2 a medias convertiría una factura de cien euros en un céntimo, y rechazar sale más barato que hacerse el listo. Hay un límite superior de 2^53 céntimos, porque el otro extremo del cable calcula con números de JavaScript, y un valor que aceptamos y que allí ya no se representa con exactitud es una divergencia silenciosa en lugar de un error.
Quince omisiones, cada una medida
Ocho valen para cualquier migración de facturación, y siete son propias de la primera ruta, de FOSSBilling a Paymenter. Las quince se midieron contra contenedores en marcha y no se tomaron de la documentación.
Las ocho generales son las que se pueden predecir en cuanto se dicen en voz alta. Las contraseñas de cliente son hashes calculados con la clave de aplicación del origen y no funcionarían en ningún otro sitio, y el endpoint de usuarios del destino exige una contraseña nueva de al menos ocho caracteres. Las tarjetas y mandatos guardados son tokens que pertenecen a la cuenta del panel de origen ante el proveedor de pago; llevarlos significaría que sale un adeudo desde una cuenta que el cliente nunca firmó. La configuración de las pasarelas lleva claves y direcciones de webhook del panel antiguo, así que deliberadamente nunca se lee. Las plantillas de correo están escritas en el lenguaje de plantillas de su propio panel, y una plantilla trasladada produce correo con marcadores sin sustituir. Los adjuntos de tickets existen como fichero en el disco del panel de origen, sin ningún endpoint que devuelva su contenido. La contabilidad de afiliados no tiene equivalente con el mismo significado, y llevarla haría nacer de nuevo comisiones ya pagadas. Los códigos de descuento sin su historial de uso son directamente incorrectos: un código limitado a diez usos que se ha usado cuatro veces llega como un código al que le quedan diez. Los secretos de doble factor están cifrados por panel y nunca se devuelven.
Las siete propias de la ruta son la mitad interesante, porque todas son límites de la API de administración del destino y no del destino en sí.
| Qué se queda atrás | Por qué, medido |
|---|---|
| Empresa, dirección, número fiscal, teléfono, idioma | La petición de alta valida nombre, apellidos, correo, contraseña, fecha de verificación y rol, y el controlador crea exactamente el conjunto validado. Todo lo demás se pierde. |
| Números de factura | invoices.number es único en la base de datos pero no aparece en ninguna de las dos reglas de validación ni en ningún recurso; no se puede escribir ni releer. |
| El impuesto en una línea | Una línea de factura acepta descripción, cantidad, precio y una referencia. No hay campo de impuesto. |
| El catálogo de productos | El controlador de productos solo tiene index y show, y el fichero de permisos concede únicamente view sobre productos y categorías. |
| Los registros de pago | La API no publica ningún endpoint de pagos ni de transacciones. |
| Un servicio ya vencido | La fecha de expiración se valida como hoy o posterior. |
| Identidades del personal | Un mensaje de ticket exige un user_id que exista como usuario, y el personal del origen no es usuario del destino. |
Dos de esas filas llevan una decisión que merece nombrarse.
Los números de factura van a stderr. Como el destino asigna su propia serie y no acepta ni devuelve el número antiguo, el migrador escribe una línea por factura en la salida de error con el número de origen, donde el registro del agente lo recoge. No es elegante. Es honesto: el número está en un documento que el cliente tiene en la mano, y el único sitio donde todavía cabe es un registro que controla quien administra.
Una factura con impuesto se bloquea en lugar de escribirse. El destino calcula el impuesto a partir de sus propios ajustes, así que la misma factura acabaría allí con un total distinto del documento ya enviado. Escribirla igualmente produce un segundo registro contradictorio de una sola operación. El plan coloca esas facturas bajo tax_not_carried y sigue adelante.
Esa distinción recorre toda la herramienta. Un producto ausente bloquea el servicio en vez de archivarlo en otro sitio. Bloquear es un resultado; adivinar no.
El origen responde HTTP 200 cuando falla
La API de administración de FOSSBilling responde a una llamada fallida con estado 200 y un cuerpo de {"result":null,"error":{"message":"...","code":9999}}. Quien mire solo el código de estado lee un fallo como una respuesta vacía, y una migración que no distingue vacío de fallido acabará algún día sincronizando una contabilidad hasta dejarla en nada. Hay exactamente una función que abre ese sobre, y mira primero error y después result.
El lector es además de solo lectura por construcción, y eso es una prueba en lugar de una promesa. Cada método que puede invocar es una constante con nombre que termina en get o en get_list, siete en total, y una prueba lee el fichero fuente y compara cada llamada client.get( con esa lista. Un lector que adquiriera una llamada _create sería una prueba en rojo y no un descubrimiento posterior.
Otra rareza medida: un producto de FOSSBilling vuelve con los siete periodos de facturación marcados como activos y con precio cero, venda lo que venda el panel. El precio es la única señal útil sobre qué ciclo es real.
Idempotencia sobre una huella, porque no hay clave externa
La forma habitual de hacer reanudable una migración es escribir un identificador propio en el destino y buscarlo la próxima vez. Paymenter no ofrece dónde ponerlo: no hay campo de referencia externa que sobreviva a una escritura y vuelva en una lectura.
Por eso la identidad es una huella calculada a partir del contenido en ambos lados.
- Un cliente es su dirección de correo, en minúsculas.
- Un servicio es correo, nombre de producto, precio en céntimos, moneda y cantidad.
- Una factura es correo, moneda, fecha de vencimiento y sus líneas, con el total sumado a partir de esas líneas.
- Un ticket es correo más asunto.
Dos de ellas llevan una advertencia en el código. La huella del servicio excluye deliberadamente la próxima fecha de vencimiento, porque el destino rechaza una fecha pasada y guarda una vacía, y entonces el mismo servicio tendría al reanudar una huella distinta de la que tenía al escribirse y se crearía por segunda vez: un campo que el destino puede cambiar no puede formar parte de la clave. El total de la factura se suma a partir de las líneas en vez de leerse del campo de total del origen, porque en el destino ese valor es un cálculo y no una columna, y dos lados que suman distinto producen una clave que nunca coincide. Las líneas se ordenan antes de la composición, porque el orden en que vuelven es el de sus identificadores, y ese no tiene por qué ser el orden en que se escribieron si una se cae.
La clave del ticket es la débil, y así está escrito: dos tickets del mismo cliente con exactamente el mismo asunto se funden en uno. Incluir el primer mensaje lo resolvería y cuesta una segunda llamada por ticket en el destino.
Tres maneras de tratar una colisión
Cuando un objeto ya existe en el destino, la ejecución hace una de tres cosas, y se elige de antemano: skip deja en paz la fila existente, overwrite_empty rellena solo los campos que allí están vacíos, y stop detiene la ejecución entera antes de escribir absolutamente nada.
Medido contra un FOSSBilling 0.8 real y un Paymenter real: una simulación planificó cuatro clientes, cuatro servicios, cuatro facturas, cinco productos y dos tickets; la ejecución creó cuatro clientes, cuatro servicios, cuatro facturas y dos tickets, con recuento posterior. Matar el proceso a mitad de camino con kill -9 y arrancarlo de nuevo creó cero objetos y dejó el destino idéntico byte a byte. Con stop hubo una parada sin una sola escritura. Con overwrite_empty solo se rellenaron los campos de nombre vacíos y los ya rellenos quedaron intactos. Una factura en una moneda no admitida volvió como un 422 que nombraba el campo, quedó anotada como currency_unknown, y la ejecución continuó.
Por qué la ruta no se llama live
La herramienta está terminada y medida, y la ruta sigue sin poder lanzarse desde el panel. La razón es una frontera que resultó importar: las credenciales de una migración se leen de la tabla de integraciones, y la columna de tipo de esa tabla es el registro de la infraestructura que operamos. Una entrada live allí lleva una página de documentación, una guía de configuración, un esquema de credenciales, una prueba de conexión y nueve traducciones, y aparece en el sitio comercial como algo con lo que se gobiernan servidores. Un panel de facturación no es eso. Se lee una vez y no se vuelve a tocar.
Llamar live a la ruta pondría encima una insignia que dice algo distinto de la pantalla que hay debajo, así que existe un tercer estado, adapter_ready, que dice lo que es cierto: el adaptador existe y se ha medido contra paneles reales, y el lugar donde introducir los dos paneles todavía no existe. Ese lugar es una ronda propia, porque guardar un panel de facturación como conexión es una decisión de esquema y no un subproducto de una herramienta de migración.
Del trabajo de migración de Provibr. Por qué una API que responde 200 ante un fallo es una clase de error y no un accidente está en por qué un 200 de Proxmox no es un resultado, y por qué una migración que saltó en silencio es peor que una que falló está en la migración que nunca se ejecutó.