Configuración inicial: empresa, monedas, impuestos y numeración de facturas

Por: Artiko
fossbillingconfiguracionmonedasimpuestosivafacturacionnumeracionlocalizacionexchange-ratesadmin

Configuración inicial: empresa, monedas, impuestos y numeración de facturas

Al terminar el capítulo 3 tienes una instancia de FOSSBilling 0.8.5 sana: config.php revisado clave por clave, path_data fuera de la raíz web, los logs rotando y console.php funcionando con el usuario correcto. Nada de eso ha tocado todavía la base de datos de negocio. La instancia sigue diciendo que tu empresa se llama Company Name, que tu email de soporte es [email protected] y que vendes en dólares.

Eso no es un detalle cosmético que puedas arreglar el mes que viene, y esta es la idea que vertebra el capítulo entero: FOSSBilling congela dentro de cada factura los datos de la empresa, la tasa de impuesto, el nombre del impuesto, la moneda, el tipo de cambio y la serie. No hay recálculo posterior, no hay “refrescar datos del vendedor”, no hay migración. Si emites diez facturas con Company Name en el campo del vendedor, tienes diez facturas con Company Name para siempre, y la única salida es borrarlas y regenerarlas. Lo mismo con el IVA: crear la regla fiscal después de facturar deja las facturas anteriores con taxrate = 0.

Por eso este capítulo no es una lista de casillas del panel: es un orden de operaciones. Al terminarlo tendrás los datos de empresa completos, el locale y el país por defecto fijados, las monedas que vas a aceptar con sus tasas de cambio sincronizándose solas desde el cron, el interruptor global de impuestos decidido con sus reglas creadas, y el esquema de numeración de facturas elegido con conocimiento de causa, incluyendo el detalle que sorprende a todo el mundo: hay un solo contador de numeración para toda la instancia, y las “series” son solo prefijos de texto.

Toda referencia de código de este capítulo corresponde al tag 0.8.5 del repositorio oficial. Las rutas src/... son relativas a la raíz del repositorio; en una instalación desplegada desde el ZIP de release, el contenido de src/ está en la raíz del document root, así que src/modules/Invoice/Service.php es modules/Invoice/Service.php en tu servidor.

El orden correcto de configuración y por qué importa: todo se congela

La secuencia no es negociable, y cada paso que te saltes deja un rastro permanente en los documentos que emitas antes de completarlo.

flowchart TD
    A["1. Datos de empresa en /admin/system"] --> B["2. Localización y lista de paises"]
    B --> C["3. Moneda por defecto y proveedor de tasas"]
    C --> D["4. tax_enabled y reglas de IVA"]
    D --> E["5. Series de factura y timing"]
    E --> F["6. Pasarela de pago"]
    F --> G["Primera venta real"]

    A -.->|"si lo dejas para despues"| A2["Facturas con seller_company igual a Company Name"]
    B -.->|"si lo dejas para despues"| B2["Direcciones de cliente sin pais y formatos de fecha erroneos"]
    C -.->|"si lo dejas para despues"| C2["invoice.currency y currency_rate congelados en la moneda equivocada"]
    D -.->|"si lo dejas para despues"| D2["invoice.taxrate igual a 0 en todo lo emitido antes"]
    E -.->|"si lo dejas para despues"| E2["Saltos y huecos en la numeracion legal"]
    F -.->|"si lo dejas para despues"| F2["Pedidos que nunca se activan por falta de pago"]

Uno. Los datos de empresa se copian a las columnas seller_* de la tabla invoice en el momento exacto en que se crea la factura, dentro de Invoice\Service::setInvoiceDefaults(). Ese método es también donde se fija serie, nr, hash, taxname, taxrate, due_at y las notas.

Dos. La moneda y su tasa quedan en invoice.currency e invoice.currency_rate. La tasa se vuelve a congelar al marcar la factura como pagada en markAsPaid(), que es lo que permite que el reporting histórico sea estable.

Tres. El impuesto se resuelve una sola vez, contra las reglas que existan en ese instante.

Advertencia: el propio panel avisa de esto en la pantalla de monedas, literalmente: «Changing [the default currency] after invoices and orders already exist can distort reporting and will not recalculate historical pricing.» No es una advertencia decorativa, es la descripción exacta del comportamiento.

Antes de empezar, comprueba en qué estado está la instalación recién instalada. Los valores por defecto vienen de src/install/sql/content.sql y viven en la tabla setting, con columnas param, value y updated_at:

mysql -u fossbilling -p fossbilling -e \
  "SELECT param, value FROM setting WHERE param IN
   ('company_name','company_email','company_tel','company_logo','company_favicon',
    'hide_company_public','invoice_series','invoice_starting_number','tax_enabled');"

Salida típica de una instalación virgen:

+-------------------------+---------------------------------+
| param                   | value                           |
+-------------------------+---------------------------------+
| company_name            | Company Name                    |
| company_email           | [email protected]         |
| company_tel             | +123 456 12345                  |
| company_logo            | public/branding/logo.svg        |
| company_favicon         | public/branding/favicon.ico     |
| hide_company_public     | 1                               |
| invoice_series          | FOSS                            |
| invoice_starting_number | 1                               |
+-------------------------+---------------------------------+

Fíjate en que tax_enabled no aparece: en una instalación limpia el parámetro sencillamente no existe todavía, y eso equivale a impuestos desactivados.

/admin/system: las ocho pestañas y qué endpoint guarda cada una

El panel de administración vive bajo el prefijo admin_area_prefix de config.php, que por defecto es /admin. La pantalla central de configuración es /admin/system, renderizada por src/modules/System/templates/admin/mod_system_settings.html.twig, y tiene exactamente ocho pestañas.

PestañaQué contieneEndpoint que guarda
Company DetailsContacto, branding, datos bancarios, firmaPOST admin/system/update_params
Company LegalTérminos, política de privacidad, nota públicaPOST admin/system/update_params
Localizationlocale, current_locale, auto_detect_localeadmin/system/update_localization_settings
Countriesdefault_country y la lista countriesadmin/extension/config_save con ext = mod_system
CacheLimpieza de la caché de la aplicaciónacciones del módulo System
AboutVersión de FOSSBilling e información de la instanciasolo lectura
Error ReportingEnvío de errores a Sentry, ligado a debug_and_monitoringPOST admin/system/update_params
Network InterfaceAjustes de red de la instanciaPOST admin/system/update_params

La lectura de todo el bloque de parámetros se hace con admin/system/get_params. Es el endpoint que conviene usar para verificar desde fuera qué tiene realmente guardada la instancia, sin fiarte de lo que muestre el formulario:

curl -s -u 'admin:TU_API_KEY' \
  -H 'Content-Type: application/json' \
  -X POST https://facturacion.ejemplo.com/api/admin/system/get_params | jq '.result'

Gotcha: tres pestañas guardan por tres endpoints distintos. Company Details y Company Legal escriben parámetros de sistema en la tabla setting; Localization tiene su propio endpoint; y Countries no escribe parámetros de sistema en absoluto, sino configuración del módulo mod_system. Si automatizas la configuración inicial vía API —algo perfectamente razonable si despliegas varias instancias— tienes que llamar a los tres, no solo a update_params. La API completa y su autenticación se cubren en el capítulo 14.

Company Details: los nueve campos de contacto y branding

El primer bloque de la pestaña Company Details se llama Contact and Branding y son nueve campos. Estos son los name reales del formulario, que coinciden con los param de la tabla setting:

name=Etiqueta en el panelDónde aparece
company_nameNameFacturas, emails, PDFs, área de cliente, asunto de los correos
company_emailEmailRemitente por defecto y dirección de contacto pública
company_telPhoneCabecera de la factura y páginas públicas
company_faviconFaviconPestaña del navegador; semilla public/branding/favicon.ico
company_address_1Address line 1Bloque de vendedor de la factura
company_address_2Address line 2Bloque de vendedor de la factura
company_address_3Address line 3Bloque de vendedor de la factura
company_logoLogoPanel, área de cliente y PDF; semilla public/branding/logo.svg
company_logo_darkDark logoVariante para tema oscuro; semilla public/branding/logo-dark.svg

company_logo_dark merece una nota: si lo dejas vacío, el tema usa el logo claro también en modo oscuro. El toggle claro/oscuro del panel llegó en 0.8.3, así que en una instancia moderna un logo claro sobre fondo oscuro se ve mal con bastante frecuencia. Es barato subir las dos variantes.

Las tres líneas de dirección son texto libre, sin estructura. FOSSBilling no las descompone en calle, ciudad y código postal para el vendedor —eso solo lo hace para el comprador—, así que decide un formato y respétalo, porque es exactamente lo que se va a imprimir en el PDF.

Gotcha con company_email: este valor es el contacto de la empresa, pero no es necesariamente el remitente de los correos salientes. Desde 0.8.0 el módulo Email tiene sus propios from_email y from_name en su configuración. Si los dejas vacíos, el correo sale con la identidad de la compañía; si los rellenas, ganan ellos. Es una fuente habitual de “configuré el email de la empresa y los correos siguen saliendo del otro”. El correo saliente se trata a fondo en el capítulo 9.

Registration and Banking: VAT, IBAN, BIC y mostrarlos en la factura

El segundo bloque de la misma pestaña son los datos fiscales y bancarios. Ocho campos:

name=EtiquetaNota
company_numberCompany numberNúmero de registro mercantil o identificador legal
company_vat_numberVAT NumberSe copia a invoice.seller_company_vat
company_bank_nameBank nameSolo se imprime si activas el interruptor
company_bicBIC / SWIFTIdem
company_account_numberAccount numberEl IBAN; el campo se llama “account number” por herencia
company_display_bank_infoCheckboxMuestra los datos bancarios en la factura
company_bank_info_pagebottomCheckboxLos coloca al pie de página en lugar de en la cabecera
hide_company_publicCheckboxOculta los datos de la compañía al público; semilla 1

Los tres campos bancarios se rellenan pero no se imprimen hasta que marcas company_display_bank_info. Es un patrón deliberado: puedes tener el IBAN guardado para uso interno y decidir por separado si aparece en el documento que ve el cliente. Si vendes con transferencia bancaria como método de pago, necesitas los dos: los datos y el interruptor.

company_bank_info_pagebottom solo cambia la posición dentro de la plantilla PDF. Con facturas de una sola línea da igual; con facturas largas, ponerlo al pie evita que los datos bancarios queden a tres páginas de distancia del total a pagar.

hide_company_public viene activado por defecto (hide_company_public = 1 en la semilla). Mientras esté a 1, las páginas públicas no exponen los datos de la compañía. Si tu jurisdicción te obliga a publicar identificación fiscal y domicilio social en el sitio web, tienes que desactivarlo conscientemente; nadie te lo va a recordar.

La firma es el campo company_signature, un textarea al final de Company Details. Su semilla de instalación es literalmente FOSSBilling.org - Client Management, Invoicing and Support Software, que es exactamente el tipo de cosa que no quieres al pie de tus correos transaccionales.

Se inyecta en las plantillas de email como {{ guest.system_email.signature }}, y ese es uno de los pocos globals permitidos por el sandbox de plantillas de correo. Cámbialo antes de que salga el primer email de bienvenida.

La pestaña Company Legal tiene tres campos, los tres con editor enriquecido:

name=Uso
company_tosTérminos de servicio
company_privacy_policyPolítica de privacidad
company_noteNota pública de la empresa

company_note es la que sorprende: se publica en /about-us. No es un campo interno ni una nota para el equipo; es el contenido de una página pública del área de cliente. Si lo dejas vacío, la página existe pero no dice nada.

Los tres valores se consolidan, junto con todo lo anterior, en el método Box\Mod\System\Service::getCompany() de src/modules/System/Service.php. Ese método devuelve un array con estas claves exactas:

www              name             email            tel
signature        logo_url         logo_url_dark    favicon_url
address_1        address_2        address_3
account_number   bank_name        bic
display_bank_info                 bank_info_pagebottom
number           note             privacy_policy   tos
vat_number

Ese array es la fuente única de verdad sobre la identidad de la empresa en toda la aplicación: lo consumen las plantillas Twig del área de cliente, las plantillas de email y —lo importante— el código que rellena los campos del vendedor en cada factura nueva.

Logos y favicon: por qué no se editan los ficheros de public/branding

Es el error documentado oficialmente y el que más tiempo cuesta diagnosticar, porque durante un rato parece que funciona.

company_logo apunta por defecto a public/branding/logo.svg. La tentación evidente es sobrescribir ese fichero con tu logo y no tocar nada más. No lo hagas: el directorio public/branding/ forma parte del core y se sobrescribe en cada actualización. Tu logo desaparece en la siguiente subida de versión, en el peor momento posible y sin ninguna pista de por qué.

El procedimiento correcto:

  1. Sube el logo desde el propio formulario de /admin/system.
  2. Deja que FOSSBilling lo coloque en data/uploads/ y ajuste el parámetro company_logo a la ruta nueva.
  3. Verifica el valor guardado, no el que muestra el formulario.
mysql -u fossbilling -p fossbilling -e \
  "SELECT param, value FROM setting WHERE param LIKE 'company_logo%' OR param='company_favicon';"

Si tras subirlo el logo no aparece, la secuencia de diagnóstico oficial es exactamente esta, en este orden:

# 1. Limpiar la cache de FOSSBilling desde la CLI, con el usuario del servidor web
sudo -u www-data php /var/www/fossbilling/console.php cache:clear

# 2. Comprobar que el fichero existe y es legible por PHP
ls -l /var/www/fossbilling/data/uploads/

# 3. Comprobar que la ruta guardada resuelve por HTTP
curl -sI https://facturacion.ejemplo.com/data/uploads/mi-logo.svg | head -1

Gotcha de nginx: la configuración oficial de nginx bloquea /data/ entero con location ^~ /data/ { return 403; }. Es una regla de seguridad correcta —ahí viven la caché, los logs y las subidas—, pero significa que la ruta de los uploads que sirve tu instalación depende de cómo esté montado el servidor web. Si el paso 3 te devuelve 403, no es un problema de FOSSBilling: revisa la configuración de nginx que dejaste en el capítulo 2. El tercer paso del diagnóstico oficial, limpiar la caché del navegador, es real y no una excusa: los SVG cacheados sobreviven a más recargas de las que uno espera.

Los datos del vendedor que se congelan en cada factura

Aquí está el mecanismo completo. La tabla invoice tiene seis columnas dedicadas al vendedor:

Columna de invoiceOrigen en getCompany()
seller_companyname
seller_company_vatvat_number
seller_company_numbernumber
seller_addressaddress_1 + address_2 + address_3
seller_phonetel
seller_emailemail

Se rellenan una sola vez, en setInvoiceDefaults(), cuando la factura nace. A partir de ahí la factura es un documento autónomo: cambiar company_name en el panel no toca ninguna fila existente de invoice.

Esto es correcto desde el punto de vista contable —una factura emitida es un documento inmutable y debe reflejar los datos del momento de la emisión— y doloroso desde el punto de vista operativo si emitiste las primeras facturas con los datos de demostración.

Para saber cuántas facturas están contaminadas:

SELECT COUNT(*) AS afectadas,
       MIN(created_at) AS primera,
       MAX(created_at) AS ultima
FROM invoice
WHERE seller_company = 'Company Name'
   OR seller_email  = '[email protected]';

No hay endpoint de API que reescriba los campos seller_* de una factura existente. Las opciones reales son dos: borrar y regenerar las facturas afectadas mientras sean pocas y estén sin pagar, o aceptarlas y seguir adelante. Por eso el orden de este capítulo empieza donde empieza.

El mismo principio aplica al comprador: las columnas buyer_* guardan nombre, empresa, VAT, dirección, ciudad, estado, país, código postal, teléfono y email del cliente tal como estaban al emitir. Si el cliente se muda, sus facturas antiguas siguen mostrando la dirección antigua, que es justo lo que debe pasar.

Localización: locale, auto_detect_locale, default_country y la lista de países

La pestaña Localization guarda con admin/system/update_localization_settings y tiene tres campos:

CampoTipoFunción
localeselectorIdioma por defecto, entre los instalados en locale/
current_localehiddenEl locale activo en la sesión que está guardando
auto_detect_localecheckboxDetecta el idioma por la cabecera Accept-Language

Hay una segunda capa de configuración de idioma en config.php, bajo la clave i18n, con locale (por defecto en_US), timezone (UTC), date_format (medium), time_format (short), datetime_pattern y auto_detect_locale (true). El panel es la vía normal; config.php es la vía que sobrevive a una base de datos recreada. Los detalles de ese bloque están en el capítulo 3.

El locale importa mucho más en 0.8.x que en versiones anteriores, y la razón es la siguiente sección: el formato de los importes ya no lo decide la moneda, lo decide el locale. Con locale = es_ES verás 1.234,56 €; con en_US verás €1,234.56. Es la misma moneda y el mismo número.

La pestaña Countries es distinta en un aspecto importante: no guarda parámetros de sistema, guarda configuración del módulo, vía admin/extension/config_save con ext = mod_system. Dos campos:

  • default_country: el país preseleccionado en los formularios de registro y de cliente.
  • countries: un textarea con la lista de países disponibles, una línea por país, en formato CODIGO=Nombre.
CL=Chile
AR=Argentina
MX=México
ES=España
US=United States

Recortar esa lista tiene dos efectos que conviene entender. El bueno: reduce el ruido en el formulario de registro y ayuda contra el spam de altas. El malo: el país del cliente es una de las tres entradas del algoritmo de resolución de impuestos. Si eliminas un país de la lista, ningún cliente nuevo podrá seleccionarlo, y por tanto ninguna regla de IVA específica de ese país llegará a aplicarse nunca. Decide la lista de países antes de crear las reglas fiscales, no después.

Monedas en 0.8.x: la entidad de tres campos y el fin del price_format

Este es el cambio de comportamiento más grande respecto a 0.7.x, y hace que buena parte de los tutoriales de FOSSBilling que encuentres indexados estén simplemente equivocados.

La entidad Doctrine es src/modules/Currency/Entity/Currency.php, tabla currency, y tiene tres campos propios además del id:

private ?int $id;
private string $code;                            // 3 letras, UNIQUE
private bool $isDefault = false;
private string $conversionRate = '1.000000';     // DECIMAL(13,6)

Eso es todo. El nombre y el símbolo de la moneda ya no se almacenan. Se resuelven en tiempo de ejecución con Symfony\Component\Intl\Currencies, a partir del código ISO. El método toApiArray() los añade al vuelo:

{ "code": "EUR", "name": "Euro", "symbol": "€", "conversion_rate": 0.92, "default": false }

Y la consecuencia que rompe expectativas: desaparece el campo format / price_format por moneda, el clásico €{{price}} que se editaba a mano en BoxBilling y en FOSSBilling 0.7.x. Ya no existe ese campo en la entidad ni en la plantilla src/modules/Currency/templates/admin/mod_currency_settings.html.twig. El formateo lo hace ahora el filtro Twig format_currency de Symfony Intl, con la firma format_currency(mixed $amount, string $currency, array $attrs = []), y los filtros currency_symbol y currency_name resuelven símbolo y nombre.

Ese cambio tiene tres implicaciones prácticas:

Uno. Si migras desde 0.7.x y tenías un formato personalizado —por ejemplo el símbolo detrás con espacio duro—, no hay dónde volver a ponerlo desde el panel. La palanca ahora es el locale, o sobrescribir la plantilla del tema.

Dos. El número de decimales tampoco se configura por moneda: sale de Symfony\Component\Intl\Currencies::getFractionDigits() aplicado a la moneda por defecto, con fallback a 2. Para monedas sin decimales, como el yen japonés o el peso chileno, esto es lo que quieres; para monedas con tres decimales lo hace correctamente sin que intervengas.

Tres. Cualquier documentación, captura o vídeo que hable de “Currency format” en FOSSBilling pertenece a una versión anterior a mayo de 2026. Si lo ves, cierra la pestaña.

La moneda sembrada en la instalación es USD con is_default = 1 y conversion_rate = 1.000000.

Alta de moneda, tasa de conversión, moneda por defecto y qué no se puede borrar

La pantalla es /admin/extension/settings/currency, y la edición de una moneda concreta está en /admin/currency/manage/:code. La API de administración vive en src/modules/Currency/Api/Admin.php:

Método APIPermisoParámetros
currency/get_listcurrency:viewpaginación
currency/get_pairscurrency:viewdevuelve todas las ISO válidas, tipo 'USD' => 'USD (US Dollar)'
currency/getcurrency:viewcode requerido
currency/get_defaultcurrency:view
currency/createcurrency:createcode requerido; conversion_rate opcional
currency/updatecurrency:editcode, conversion_rate
currency/set_defaultcurrency:set_defaultcode
currency/deletecurrency:deletecode
currency/update_ratescurrency:update_ratesfuerza la sincronización de todas
currency/is_cron_enabledcurrency:viewtrue si sync_rate no es never

Dar de alta el euro con tasa automática y ponerlo como moneda por defecto:

BASE=https://facturacion.ejemplo.com/api/admin
AUTH='admin:TU_API_KEY'

# conversion_rate en blanco = que lo resuelva el proveedor configurado
curl -s -u "$AUTH" -H 'Content-Type: application/json' \
  -X POST "$BASE/currency/create" -d '{"code":"EUR"}'

curl -s -u "$AUTH" -H 'Content-Type: application/json' \
  -X POST "$BASE/currency/set_default" -d '{"code":"EUR"}'

curl -s -u "$AUTH" -X POST "$BASE/currency/update_rates"

Dos reglas duras del código:

La moneda por defecto no se puede borrar. currency/delete la rechaza. Para eliminarla tienes que promover otra con set_default primero.

Cambiar la moneda por defecto con facturas y pedidos ya existentes distorsiona el reporting y no recalcula los precios históricos. Lo dice el panel y lo confirma el código: la conversión a moneda base para informes la hace Currency\Service::toBaseCurrency(), que se usa desde Invoice\Service::countIncome() para rellenar invoice.base_income e invoice.base_refund. Esas columnas se calcularon con la moneda base que existía en su momento. Si cambias la base después, los agregados históricos y los nuevos dejan de ser comparables. No es un bug, es aritmética.

La conclusión operativa: elige la moneda por defecto el primer día y no la toques. Añadir monedas adicionales después es barato; cambiar la base no lo es.

Tasas automáticas: los tres proveedores, sync_rate y el TTL real de la caché

La configuración de tasas está en la pestaña de integraciones y automatización de la pantalla de moneda, y se guarda con admin/extension/config_save usando ext = mod_currency:

Clave de configValoresDefault
providerexchangerate-api, currency_data_api, currencylayerexchangerate-api
exchangerate_api_keystring; vacío usa el endpoint de acceso abierto''
currencydata_keystring, obligatorio si provider = currency_data_api
currencylayer_keystring, obligatorio si provider = currencylayer
sync_rateauto, never, 1d, 1h, 10m, 5m, 1mauto

Los endpoints HTTP reales que usa ExchangeRate-API:

Sin clave:  https://open.er-api.com/v6/latest/{FROM}
Con clave:  https://v6.exchangerate-api.com/v6/{KEY}/latest/{FROM}

Currency Data API y currencylayer son ambos de APILayer; el propio panel explica que sirven los mismos datos a través de dos APIs distintas, y cada uno exige su clave.

La opción auto solo está disponible para ExchangeRate-API. El proveedor informa en la respuesta cuándo habrá datos nuevos, y FOSSBilling solo vuelve a pedir entonces. El JavaScript del panel deshabilita auto si eliges cualquiera de los otros dos, y a cambio les habilita 1m.

Ahora el detalle que importa de verdad: sync_rate no es una frecuencia de ejecución, es un TTL de caché. La traducción exacta está en Currency\Service::getRate():

sync_rateTTL en segundos
1h3600
10m600
5m300
1m60
never0
1d86400
auto86400

Sí: auto y 1d colapsan al mismo valor, 86400. Y cualquier valor que no esté en esa lista cae también en 86400 por el caso por defecto.

Gotcha: poner sync_rate = 1m no hace que las tasas se actualicen cada minuto. Solo hace que la caché caduque cada minuto. Quien decide cuándo se intenta la actualización es el cron, y si tu crontab corre cada cinco minutos —la frecuencia recomendada—, la resolución real es de cinco minutos. Configurar 1m con un cron de cinco minutos no gana nada y consume cuota del proveedor cinco veces más rápido de lo necesario.

La sincronización es un listener del cron, no una tarea de la lista

Este es el segundo malentendido habitual sobre las tasas de cambio. Si abres /admin/extension/settings/cron buscando una tarea llamada “update currency rates”, no está.

La sincronización se dispara mediante el hook estático Box\Mod\Currency\Service::onBeforeAdminCronRun(), que llama a updateCurrencyRates() únicamente si isCronEnabled() devuelve true —es decir, si sync_rate no vale never—. Es un listener de evento enganchado al arranque del cron, no una entrada de la lista fija de tareas programadas.

sequenceDiagram
    participant CT as "crontab del sistema"
    participant CP as "cron.php"
    participant CS as "Cron Service runCrons"
    participant HK as "Hook onBeforeAdminCronRun"
    participant CU as "Currency Service"
    participant API as "Proveedor HTTP"
    participant DB as "Tabla currency"

    CT->>CP: "cada 5 minutos php cron.php"
    CP->>CS: runCrons
    CS->>HK: fire onBeforeAdminCronRun
    HK->>CU: onBeforeAdminCronRun
    CU->>CU: isCronEnabled comprueba sync_rate distinto de never
    alt "sync_rate es never"
        CU-->>CS: "no hace nada"
    else "habilitado"
        CU->>CU: getRate con TTL de cache segun sync_rate
        alt "cache vigente"
            CU-->>CS: "devuelve la tasa cacheada"
        else "cache caducada"
            CU->>API: "GET latest FROM"
            API-->>CU: "tasas actuales"
            CU->>DB: "UPDATE currency conversion_rate"
        end
    end

Consecuencias prácticas de que sea un listener y no una tarea:

Uno. La frecuencia efectiva máxima la marca tu crontab. El comando recomendado es */5 * * * * php /ruta/absoluta/a/fossbilling/cron.php, y el panel muestra una advertencia si pasan más de 15 minutos sin una ejecución.

Dos. Si el cron no corre, las tasas se congelan sin ningún mensaje de error específico de monedas. El síntoma que verás es el aviso genérico de cron parado en el dashboard.

Tres. Puedes forzar una sincronización inmediata sin esperar al cron, con currency/update_rates o ejecutando el cron a mano:

sudo -u www-data php /var/www/fossbilling/console.php cron:run

Cuatro. Desde 0.8.5 los fallos parciales del cron terminan con código de salida distinto de cero, lo que permite que tu monitorización se entere. Es una razón concreta para no quedarse en 0.8.0.

Errores de moneda que rompen pedidos y facturas, y cómo repararlos

Hay tres mensajes de error de moneda que verás tarde o temprano. Los tres son literales del código.

Unable to fetch conversion rate for currency: XXX. El proveedor configurado no cubre esa divisa. No es un fallo de red ni de clave: sencillamente esa moneda no está en la respuesta. La salida es cambiar de proveedor o fijar la tasa a mano con currency/update.

Currency rate for 'XXX' is not configured Este es el grave, porque rompe operaciones de negocio. Se lanza desde dos sitios: Order\Service::createOrder() e Invoice\Service::markAsPaid(), cuando getRateByCode() devuelve null. Traducido: hay una moneda en juego que no tiene tasa registrada, y eso impide crear el pedido o marcar la factura como pagada. Un cliente con client.currency = 'EUR' en una instancia donde el euro nunca se dio de alta reproduce el error de forma perfecta.

Diagnóstico y reparación:

-- ¿Qué monedas usan realmente tus clientes y pedidos?
SELECT currency, COUNT(*) FROM client       GROUP BY currency;
SELECT currency, COUNT(*) FROM client_order GROUP BY currency;

-- ¿Cuáles están dadas de alta y con qué tasa?
SELECT code, is_default, conversion_rate FROM currency;

Toda moneda que aparezca en las dos primeras consultas y no en la tercera es una bomba de relojería. Créala y sincroniza:

curl -s -u "$AUTH" -H 'Content-Type: application/json' \
  -X POST "$BASE/currency/create" -d '{"code":"EUR","conversion_rate":"0.920000"}'
curl -s -u "$AUTH" -X POST "$BASE/currency/update_rates"

You must configure your API key to use Currency Data API as an exchange rate data source. Elegiste currency_data_api sin rellenar currencydata_key. El equivalente aplica a currencylayer con currencylayer_key. Es el único de los tres que se arregla exclusivamente en la configuración del módulo.

Advertencia: el error de tasa no configurada aparece en el momento del pedido o del pago, no en el momento de configurar. Es decir, lo descubres cuando un cliente real está intentando comprar. Recorre las tres consultas SQL de arriba antes de abrir la tienda, no después.

Impuestos: interruptor global, exención por cliente y el algoritmo de resolución

El sistema de impuestos de FOSSBilling es deliberadamente simple: reglas planas por país y estado, sin categorías fiscales por producto, sin regímenes especiales y sin validación de números de IVA intracomunitarios. El propio proyecto tiene abierto el issue #52 Rewrite the whole tax system en el milestone 1.0.x, así que esto va a cambiar, pero hoy es lo que hay y funciona bien para el caso normal.

Tres piezas:

  • Interruptor global tax_enabled, un checkbox en la misma página de impuestos, guardado como parámetro de sistema. Si está apagado, Client\Service::isClientTaxable() devuelve false para todos los clientes sin excepción, y el impuesto es 0 en todas partes.
  • Exención individual, la columna client.tax_exempt. Si vale 1, ese cliente no paga impuesto aunque haya reglas que casen con su dirección.
  • Reglas, filas de la tabla tax a través del modelo Model_Tax, con los campos name, country, state y taxrate.

El orden de resolución lo implementa ServiceTax::getTaxRateForClient() en src/modules/Invoice/ServiceTax.php, y es estrictamente este:

flowchart TD
    A["getTaxRateForClient con un cliente"] --> B{"tax_enabled activo"}
    B -- No --> Z["tasa 0"]
    B -- Si --> C{"cliente con tax_exempt"}
    C -- Si --> Z
    C -- No --> D["Buscar regla con state igual al del cliente Y country igual al del cliente"]
    D -- "encontrada" --> R["Devolver taxrate y taxname"]
    D -- "no hay" --> E["Buscar regla solo por country del cliente"]
    E -- "encontrada" --> R
    E -- "no hay" --> F["Buscar regla global con state y country vacios"]
    F -- "encontrada" --> R
    F -- "no hay" --> Z

De la más específica a la más general, y el primer acierto gana. Esto permite el patrón habitual: una regla global de respaldo con la tasa de tu país, más reglas específicas por país para los destinos donde tributas distinto.

El cálculo del importe lo hace ServiceTax::getTax(Model_Invoice), que suma invoiceItemService->getTax($item) * $item->quantity para las líneas que tengan taxed = 1. Las líneas no marcadas como imponibles no suman impuesto, sea cual sea la regla.

Crear reglas de IVA, tax_setup_eu y el congelado de taxrate y taxname

Las rutas del panel son /admin/invoice/tax para el listado y el alta, y /admin/invoice/tax/:id para editar una regla concreta. Las plantillas son mod_invoice_tax.html.twig y mod_invoice_taxupdate.html.twig.

El formulario de alta tiene cuatro campos:

CampoEjemploSemántica de vacío
nameIVA 19%Es el taxname que se imprimirá en la factura
taxrate19Porcentaje, no fracción
countryCLVacío significa todos los países
statevacíoVacío significa todos los estados

La API completa está en src/modules/Invoice/Api/Admin.php y toda ella requiere el permiso invoice:manage_tax:

invoice/tax_create      invoice/tax_update     invoice/tax_get
invoice/tax_get_list    invoice/tax_delete     invoice/batch_delete_tax
invoice/tax_setup_eu

invoice/tax_setup_eu es el atajo que casi nadie conoce: crea de golpe las reglas de IVA de los países de la Unión Europea. Si vendes a la UE, ahorra crear veintitantas reglas a mano. Si no vendes a la UE, no lo llames: te llena la tabla tax de reglas que solo añaden ruido al diagnóstico.

Crear la regla global de respaldo y una específica:

# Regla global: se aplica a cualquier cliente sin regla mas especifica
curl -s -u "$AUTH" -H 'Content-Type: application/json' \
  -X POST "$BASE/invoice/tax_create" \
  -d '{"name":"IVA 19%","taxrate":19}'

# Regla especifica de pais
curl -s -u "$AUTH" -H 'Content-Type: application/json' \
  -X POST "$BASE/invoice/tax_create" \
  -d '{"name":"VAT 21%","taxrate":21,"country":"ES"}'

Y ahora la parte que cierra el círculo del capítulo: la tasa y el nombre del impuesto se congelan en invoice.taxrate e invoice.taxname al crear la factura, dentro del mismo setInvoiceDefaults() que congela los datos del vendedor.

Las facturas emitidas antes de que existiera la regla se quedan con taxrate = 0 para siempre. No se recalculan al aprobar, ni al pagar, ni al regenerar el PDF. Comprobación:

SELECT id, serie, nr, status, taxname, taxrate, created_at
FROM invoice
WHERE taxrate = 0 OR taxrate IS NULL
ORDER BY id;

Si esa consulta devuelve filas que deberían llevar IVA, la única reparación real es borrar esas facturas y regenerarlas. Por eso el paso 4 del orden de configuración va antes del paso 6.

Un caso límite que conviene tener presente: cambiar el taxrate de una regla existente no afecta a las facturas ya emitidas, y eso es exactamente lo que quieres cuando sube el IVA. La subida se aplica a lo nuevo y lo viejo conserva su tasa histórica. Es de los pocos sitios donde el congelado juega a tu favor sin matices.

Numeración: serie de proforma, serie de pagada y el contador único

La pantalla de ajustes de factura se llega desde /admin/invoice, la plantilla es src/modules/Invoice/templates/admin/mod_invoice_settings.html.twig y guarda con POST admin/system/update_params, igual que los datos de empresa. Es decir: todo esto vive en la tabla setting.

El bloque Sequential Numbering tiene cinco campos:

name=EtiquetaSemillaSemántica
remove_after_daysRemove Unpaid Invoices After00 conserva las impagadas para siempre
invoice_number_paddingInvoice Number Padding Length5mínimo 0, máximo 20
invoice_seriesInvoice Prefix / SeriesFOSSserie de las proformas o no pagadas
invoice_series_paidPaid Invoice Prefix / Seriesvacíoserie que se reasigna al pagar
invoice_starting_numberNext Paid Invoice Number1contador global, se autoincrementa

Lo primero que hay que entender es que FOSSBilling no tiene dos tablas para proformas y facturas: es la misma fila. Al crearse, la factura nace con status = unpaid, approved = 0 y serie = invoice_series. En generateForOrder() la variable interna se llama literalmente $proforma. Al pagarse, esa misma fila cambia: serie se reemplaza por invoice_series_paid, approved = 1, status = paid, paid_at = now y currency_rate queda congelado.

Y lo segundo, que es donde se rompen la mitad de los esquemas de numeración que la gente diseña: hay un único contador para toda la instancia, invoice_starting_number. Las series son prefijos de texto, no secuencias independientes.

flowchart LR
    A["Creacion de la factura"] --> B["serie igual a invoice_series<br/>nr igual a invoice_starting_number<br/>despues incrementa el contador"]
    B --> C{"Se paga"}
    C -- "Si" --> D["serie se reemplaza por invoice_series_paid<br/>el nr NO cambia"]
    C -- "No" --> E["Sigue como proforma con la serie original"]
    D --> F{"Se reembolsa"}
    F -- "credit_note" --> G["Documento nuevo con serie invoice_cn_series<br/>y contador propio invoice_cn_starting_number"]
    F -- "negative_invoice" --> H["Documento nuevo con serie invoice_series_paid<br/>y el contador global"]
    B -.-> P["El padding solo afecta a la presentacion"]
    D -.-> P

La consecuencia directa: si configuras invoice_series = 'PRO-' e invoice_series_paid = 'FAC-', no obtienes dos secuencias PRO-00001, PRO-00002 y FAC-00001, FAC-00002. Obtienes un único flujo de números en el que cada documento conserva su número al pagarse y solo cambia de prefijo. Una proforma PRO-00007 que se paga se convierte en FAC-00007, y el 00008 se lo lleva el siguiente documento que se emita, pagado o no.

Si tu normativa fiscal exige una secuencia continua y sin huecos solo para las facturas pagadas, este modelo no la produce por sí solo, porque las proformas que nunca se pagan consumen números del mismo contador. Es una limitación estructural de 0.8.5, no una opción mal configurada. El proyecto tiene abierto el issue #3484 Date-based invoice numbering with reset en el milestone 0.9.x, lo que confirma que el modelo actual se considera insuficiente.

Padding, remove_after_days y la mecánica de getNextInvoiceNumber()

El método que reparte números es Invoice\Service::getNextInvoiceNumber(), y su lógica es esta:

$next_nr = $systemService->getParamValue('invoice_starting_number');
// ... fallback: MAX(nr)+1 leyendo la ultima factura con nr no nulo
$systemService->setParamValue('invoice_starting_number', intval($next_nr) + 1);
return $next_nr;

Lee el parámetro, lo incrementa, lo vuelve a guardar y devuelve el valor original. Tres cosas que se deducen de ahí:

Uno. El “número siguiente” es un parámetro de sistema editable. Puedes ponerlo donde quieras desde el panel, por ejemplo para continuar la numeración de un sistema anterior. Si migras desde otra herramienta y tu última factura fue la 1247, pon invoice_starting_number = 1248 antes de emitir la primera.

Dos. Hay un fallback: si el parámetro no está disponible, se calcula MAX(nr) + 1 leyendo la última factura con nr no nulo. Es una red de seguridad razonable, pero significa que un nr anómalo en la base de datos puede desplazar la secuencia entera.

Tres. No hay bloqueo explícito descrito para ese incremento. En la práctica, con el volumen de un negocio normal y las facturas generándose desde el cron, no es un problema; conviene tenerlo presente si escribes automatizaciones que creen facturas en paralelo contra la API.

El padding merece su propio párrafo porque su nombre engaña. invoice_number_padding acepta de 0 a 20 y lo aplica Invoice\Service::getInvoiceNumberPadding(), pero solo afecta a la presentación. El valor almacenado en la columna invoice.nr, que es varchar(255), sigue siendo el número desnudo. Con invoice_number_padding = 5, la factura número 7 se muestra como 00007 y se guarda como 7.

Compruébalo tú mismo:

SELECT id, serie, nr, CONCAT(serie, LPAD(nr, 5, '0')) AS como_se_muestra, status
FROM invoice ORDER BY id DESC LIMIT 10;

La implicación práctica: cualquier integración externa que consulte la base de datos directamente y espere 00007 va a recibir 7. Y si cambias el padding a mitad de año, los documentos antiguos se reformatean al mostrarlos, porque el relleno se aplica al pintar, no al guardar. Con facturas ya enviadas en PDF eso genera dos representaciones distintas del mismo documento. Elige el padding el primer día.

remove_after_days es el último campo del bloque y no tiene nada que ver con la numeración: define tras cuántos días se eliminan las facturas impagadas. Su semilla es 0, que significa conservarlas siempre. Activarlo tiene un efecto colateral que conviene pensar despacio: borrar proformas impagadas no libera los números que consumieron, así que deja huecos permanentes en la secuencia.

Invoice Timing, Document Settings, hash de factura y Add Funds

Los tres bloques restantes de la misma pantalla completan la configuración de facturación.

Invoice Timing decide cuándo nacen las facturas y cuándo se reclaman:

name=EtiquetaSemilla
invoice_issue_days_before_expireGenerate New Invoice Before Order Expiration14
invoice_due_daysInvoice Due Days5, con fallback 1 en código
invoice_auto_approvalEnable Auto Invoice Approval1
invoice_reminder_before_due_daysPayment Reminder Days Before Duevacío, deshabilitado
invoice_reminder_after_due_daysPayment Reminder Days After Due5
invoice_default_noteDefault Invoice Notevacío, soporta markdown

Los dos campos de recordatorio aceptan listas separadas por comas: 14, 7, 1 envía tres avisos. Los parsea Invoice\Service::parseInvoiceReminderIntervals(). Vacío significa desactivado, no “por defecto”.

invoice_auto_approval importa más de lo que parece. Una factura con approved = 0 no entra en los batches de recordatorio ni en los eventos de vencimiento, porque las consultas SQL correspondientes filtran por approved = 1. Si desactivas la aprobación automática pensando en revisar cada factura a mano y luego no las revisas, tienes facturas emitidas de las que nadie avisa nunca al cliente.

invoice_default_note alimenta la columna invoice.notes y también se congela al crear la factura. Cambiarlo no toca las anteriores.

Document Settings controla el documento y su acceso:

name=ValoresDefault
invoice_document_formatLetter o A4radio, sin semilla
invoice_accessible_from_hash1 o 0apagado
invoice_hash_lifetime_daysnúmero; 0 es sin caducidad90
invoice_email_attach_pdf1 o 0apagado

El hash es una cadena hexadecimal de 30 a 60 caracteres generada con bin2hex(random_bytes(random_int(15, 30))) y guardada en invoice.hash, con índice UNIQUE. Su caducidad vive en invoice.hash_expires_at y llegó en 0.8.2, junto con el rate limiting de las APIs de invitado.

Advertencia seria sobre invoice_accessible_from_hash: con esta opción activada, cualquiera que tenga el enlace ve nombre, email, dirección completa, teléfono, país y número de IVA del comprador, sin autenticarse. El propio panel lo advierte. Es cómodo —el cliente abre su factura desde el correo sin iniciar sesión— y es una exposición de datos personales que hay que decidir a conciencia. Los 90 días por defecto acotan el riesgo, y el tiempo de vida se extiende automáticamente cada vez que se reenvía el email de la factura, vía extendInvoiceHashLifetime(). Desde 0.8.3 los hashes que falten se regeneran solos.

El PDF lo genera Dompdf con la plantilla src/modules/Invoice/templates/pdf/default-invoice.twig y su default-invoice.css. El formato de página sale de invoice_document_format. Adjuntar el PDF a los correos, con invoice_email_attach_pdf, está disponible desde 0.8.4.

Refunds Settings es un radio, invoice_refund_logic, con tres valores: negative_invoice, credit_note y manual. La semilla de instalación es credit_note, que es el modo con numeración independiente. La mecánica completa de reembolsos se cubre en el capítulo 8; lo relevante aquí es la numeración: en modo credit_note los abonos usan invoice_cn_series, con semilla CN-, y su propio contador invoice_cn_starting_number, con semilla 1.

Add Funds Settings son dos campos, funds_min_amount con semilla 10 y funds_max_amount con semilla 200, que acotan cuánto saldo puede recargar un cliente de una vez. Vacío significa ilimitado. Si no usas el balance de cliente, déjalos como están; si lo usas, esos 200 por defecto son un techo bajo para muchos negocios.

Por último, tres parámetros relacionados que no aparecen en ninguna pantalla y solo se ven en la tabla setting:

ParámetroSemillaQué es
invoice_cn_seriesCN-Prefijo de las notas de crédito
invoice_cn_starting_number1Contador independiente de notas de crédito
invoice_overdue_invokedMarca interna de “ya lancé el batch de vencidas hoy”

Los dos primeros son configurables directamente en la base de datos si necesitas otro prefijo o continuar una numeración de abonos anterior. El tercero es estado interno del throttling diario que implementa doBatchInvokeDueEvent() con once_per_day = true: si han pasado menos de 86400 segundos, no vuelve a lanzarse. Tocarlo a mano es la forma de forzar el batch en un día en el que ya se ejecutó.

Verificación final antes de la primera venta

Un solo bloque para comprobar que los cinco pasos previos a la pasarela están cerrados:

-- 1. Datos de empresa reales, sin semillas de demostracion
SELECT param, value FROM setting
WHERE param LIKE 'company_%' ORDER BY param;

-- 2. Moneda por defecto y tasas presentes
SELECT code, is_default, conversion_rate FROM currency;

-- 3. Impuestos: interruptor y reglas
SELECT param, value FROM setting WHERE param = 'tax_enabled';
SELECT id, name, country, state, taxrate FROM tax ORDER BY country, state;

-- 4. Numeracion y timing
SELECT param, value FROM setting
WHERE param IN ('invoice_series','invoice_series_paid','invoice_starting_number',
                'invoice_number_padding','invoice_due_days','invoice_auto_approval',
                'invoice_cn_series','invoice_cn_starting_number','remove_after_days');

-- 5. Nada contaminado todavia
SELECT COUNT(*) AS facturas_existentes FROM invoice;

Si la última consulta devuelve 0, estás a tiempo de cambiar cualquier cosa sin consecuencias. Si devuelve algo distinto de cero, cada decisión pendiente ya tiene un coste.

Errores comunes y diagnóstico

SíntomaCausa probableCómo diagnosticarloSolución
Las facturas siguen diciendo Company NameLos datos del vendedor se congelan en setInvoiceDefaults()SELECT COUNT(*) FROM invoice WHERE seller_company='Company Name'Regenerar las facturas afectadas; no hay endpoint que las reescriba
El logo desaparece tras actualizarSe sobrescribió public/branding/logo.svg en lugar de subirlo por el panelSELECT value FROM setting WHERE param='company_logo'Subirlo desde /admin/system y dejar que apunte a data/uploads/
El logo subido no se veCaché de la aplicación, caché del navegador o ruta bloqueadacurl -sI a la URL del ficheroconsole.php cache:clear, recarga forzada y revisar location ^~ /data/ en nginx
El IVA sale 0 en todas las facturastax_enabled apagado, client.tax_exempt = 1, o ninguna regla casaRecorrer el diagrama de resolución con el país y estado reales del clienteActivar tax_enabled y crear una regla global de respaldo
El IVA solo falta en las facturas antiguasLa regla se creó después de emitirlasSELECT id, taxrate, created_at FROM invoice WHERE taxrate=0Regenerar esas facturas; las nuevas ya salen bien
Currency rate for 'XXX' is not configuredMoneda usada sin fila en currency o sin tasaComparar SELECT currency FROM client_order GROUP BY currency con SELECT code FROM currencycurrency/create con la moneda y luego currency/update_rates
Unable to fetch conversion rate for currency: XXX.El proveedor no cubre esa divisaProbar el endpoint del proveedor a mano con curlCambiar de proveedor o fijar conversion_rate con currency/update
You must configure your API key to use Currency Data API...provider = currency_data_api sin currencydata_keyRevisar la config de mod_currencyRellenar la clave o volver a exchangerate-api
Las tasas no se actualizan nuncasync_rate = never, o el cron no correcurrency/is_cron_enabled y SELECT value FROM setting WHERE param='last_cron_exec'Poner sync_rate distinto de never y arreglar el crontab de 5 minutos
Puse sync_rate = 1m y sigue actualizando cada 5sync_rate es un TTL de caché, no una frecuenciaComparar con la frecuencia real del crontabBajar la frecuencia del cron, o aceptar la resolución de 5 minutos
No encuentro la tarea de cron de monedasNo existe: es el listener onBeforeAdminCronRun()Buscar en la lista de tareas programadasForzar con currency/update_rates o console.php cron:run
No puedo borrar una monedaEs la moneda por defectoSELECT code, is_default FROM currencyPromover otra con currency/set_default primero
Los informes históricos dejaron de cuadrarSe cambió la moneda por defecto con facturas existentesComparar invoice.base_income con invoice.total por periodoNo tiene reparación automática; no cambiar la moneda base
No encuentro el campo de formato de precioprice_format no existe desde 0.8.0Revisar Entity/Currency.php: solo code, isDefault, conversionRateEl formato lo decide el locale vía format_currency
Las proformas y las pagadas comparten numeraciónHay un solo invoice_starting_numberSELECT serie, nr FROM invoice ORDER BY idEs el diseño de 0.8.5; las series son prefijos de texto
El nr en base de datos no lleva cerosEl padding solo se aplica al mostrarSELECT nr FROM invoice LIMIT 5Aplicar el LPAD en tu integración externa
No llegan recordatorios de pagoapproved = 0, o los campos de recordatorio vacíosSELECT id,status,approved,due_at,reminded_at FROM invoice WHERE status='unpaid'Activar invoice_auto_approval y rellenar invoice_reminder_after_due_days
El cliente no puede seleccionar su paísSe recortó la lista countries del módulo mod_systemRevisar la pestaña CountriesAñadir la línea CODIGO=Nombre correspondiente
Cambié algo en /admin/system y no se reflejaCaché de la aplicaciónComparar el valor en la tabla setting con lo que vessudo -u www-data php console.php cache:clear

Lo que queda operativo

La instancia ya tiene identidad propia: nombre, email, teléfono, dirección, número de registro y número de IVA reales, con el logo y el favicon subidos por el canal que sobrevive a las actualizaciones, y con la firma y los textos legales puestos, incluida la nota que se publica en /about-us. El locale y la lista de países están fijados antes de que ningún cliente se registre, que es el orden correcto porque el país del cliente alimenta la resolución de impuestos.

Las monedas están dadas de alta con el modelo nuevo de tres campos, sabiendo que el formato lo decide el locale y no un price_format que ya no existe; el proveedor de tasas está elegido con su clave, sync_rate está puesto con conocimiento de que es un TTL y no una frecuencia, y la sincronización cuelga del listener onBeforeAdminCronRun() que tu crontab de cinco minutos dispara. Los impuestos tienen su interruptor global y al menos una regla de respaldo, creadas antes de la primera factura para que taxrate no se congele a cero. Y la numeración está decidida entendiendo que hay un contador único, que las series son prefijos y que el padding es cosmética.

Con la instancia lista para emitir documentos correctos, el siguiente paso es darle algo que vender. En el capítulo 5 montas el catálogo: los tipos de producto que soporta el core, la tabla de precios recurrentes con sus siete periodos, los setup fees, los addons y las promociones con su sistema de redenciones reservadas.