Catálogo de productos: tipos, precios recurrentes, addons y promociones

Por: Artiko
fossbillingproductoscatalogopreciosrecurrenteaddonspromocionescuponesformbuilderstockhosting

Catálogo de productos: tipos, precios recurrentes, addons y promociones

En el capítulo 4 dejaste la instalación con datos de empresa reales, una moneda por defecto con tasa de cambio, reglas de IVA y una numeración de facturas que no se rompe. Todo eso es infraestructura de facturación: el molde. Lo que todavía no existe es lo que vas a vender.

El catálogo de FOSSBilling parece un CRUD de productos hasta que descubres tres cosas que cambian tus decisiones de negocio. Uno: el tipo de producto no es una etiqueta, es el módulo PHP que va a aprovisionar el servicio, y hay una constante de tipo (vps) que no tiene módulo en el core. Dos: los precios no viven en una tabla de tarifas flexible, sino en un puñado de columnas fijas con un trío por periodo, lo que impone un límite duro: hay periodos que un pedido acepta pero que el catálogo no puede tarificar. Tres: no hay prorrateo en ninguna parte del código, así que un cambio de plan a mitad de ciclo es un flujo semi-manual por ticket.

Este capítulo recorre el catálogo completo con el código de FOSSBilling 0.8.5 delante: la entidad Product columna a columna, la tabla product_payment con su mapa de prefijos, los tres momentos de activación, el control de stock, los addons con su group_id obligatorio, el sistema de cupones con redenciones reservadas y el alta completa de un producto por API. Al final tendrás un catálogo que se puede pedir, tarificar y descontar sin sorpresas en el checkout.

Todas las rutas src/... son relativas a la raíz del repositorio oficial, leídas en el tag 0.8.5.

Los siete tipos de producto del código y los seis módulos Service* que existen

Las constantes de tipo están en src/modules/Product/Service.php:

final public const string CUSTOM       = 'custom';
final public const string LICENSE      = 'license';
final public const string ADDON        = 'addon';
final public const string DOMAIN       = 'domain';
final public const string DOWNLOADABLE = 'downloadable';
final public const string HOSTING      = 'hosting';
final public const string VPS          = 'vps';

Siete constantes. Cada tipo se resuelve en tiempo de ejecución a un módulo de servicio llamado Service{Tipo}, que es quien de verdad crea, activa, suspende, renueva y cancela el servicio detrás del pedido. Los módulos de servicio realmente instalados en el core 0.8.5 son seis:

src/modules/
  Servicehosting/
  Servicedomain/
  Servicedownloadable/
  Servicelicense/
  Servicecustom/
  Serviceapikey/

Compara las dos listas y verás que no encajan. Esta es la tabla honesta:

Constante de tipoMódulo de servicio en el coreDocumentado oficialmenteQué hace realmente
hostingServicehostingCrea la cuenta en cPanel/WHM, Hestia, CWP, Plesk, DirectAdmin
domainServicedomainRegistra/transfiere el dominio vía Registrar_Adapter
downloadableServicedownloadableEntrega un fichero descargable
licenseServicelicenseEmite y valida claves de licencia
customServicecustomNo aparece como “tipo” en la guíaServicio genérico sin aprovisionamiento automático
addonNoMarca de rol, no de aprovisionamiento (ver la sección de addons)
vpsNo existe en el coreNoConstante huérfana: requiere una extensión que registre el módulo

Además hay un módulo Serviceapikey que sí existe y sí se documenta (API Keys) pero que no aparece como constante en la lista anterior. La documentación oficial de tipos de producto (https://docs.fossbilling.org/admin-guide/product-types/) documenta cinco: Hosting, Domains, Downloadable Products, Licenses y API Keys.

La fuente de verdad en tu instalación no es ninguna de las dos listas, sino el endpoint:

curl -s -u "admin:$KEY" "https://tu-dominio/api/admin/product/get_types"

admin/product/get_types devuelve los tipos realmente registrados en ese despliegue, con los módulos instalados y activos que haya. Úsalo antes de escribir cualquier script de alta masiva.

Gotcha: si intentas crear un producto con un tipo que no está registrado, el alta falla con Product type :type is not registered. y HTTP 413. Es el error que te vas a comer si copias un tutorial que crea productos vps sin haber instalado la extensión correspondiente.

Hay una regla que no está en ninguna pantalla del panel y que sorprende siempre: solo puede existir un producto de tipo domain en toda la instalación. El segundo intento devuelve:

You have already created domain product.   (HTTP 413)

El error se lanza desde Product\Api\Admin::prepare(), es decir, en el momento del alta, antes de que puedas configurar nada.

El motivo es estructural: el producto de dominio no lleva precio propio. Los precios de dominio viven en la tabla tld, con price_registration, price_renew, price_transfer y min_years por extensión, y el producto domain es solo el envoltorio que conecta el carrito con el módulo Servicedomain. Toda la tarificación real de dominios la verás en el capítulo 12.

Consecuencia práctica para tu catálogo: no diseñes un producto por TLD. Si tu plan era crear “Dominio .com”, “Dominio .cl” y “Dominio .net” como tres productos, olvídalo; es un producto domain y tres filas en tld.

Los dos errores 413 del catálogo, juntos, para que los reconozcas en el log:

MensajeHTTPDónde se lanzaCausa
Product type :type is not registered.413Product\Api\Admin::prepare()El módulo Service{tipo} no está instalado o activo
You have already created domain product.413Product\Api\Admin::prepare()Ya existe un producto domain

Anatomía de la tabla product: las columnas que cambian el comportamiento

La entidad está en src/modules/Product/Entity/Product.php y la tabla es product. No todas las columnas pesan igual: unas son cosmética y otras cambian el flujo de pedido entero.

ColumnaTipoDefaultEfecto de negocio
product_category_idint nullablenullCategoría en el listado público
product_payment_idint nullablenullFK a la fila de precios en product_payment
form_idint nullablenullFormulario de pedido de Formbuilder
titlevarchar(255)Nombre visible
slugvarchar(255)URL única de la página de pedido
descriptiontextDescripción pública
unitvarchar(50)'product'Unidad que se imprime en la línea de factura
activebooltrue
statusvarchar(50)'enabled'enabled / disabled
hiddenboolfalseOculto de los listados, sigue accesible por enlace directo
is_addonboolfalseConvierte el producto en addon
setupvarchar(50)'after_payment'after_order / after_payment / manual
addonstext JSONnullIDs de addons asociados
icon_urlvarchar(255)null
allow_quantity_selectboolfalseEl cliente elige cantidad en el carrito
stock_controlboolfalseActiva el control de existencias
quantity_in_stockint0Existencias restantes
pluginvarcharnullIntegración con server manager
plugin_configtextnullConfiguración del plugin
upgradestext JSONnullIDs de productos destino de upgrade
priorityintnullOrden de aparición
configtext JSONnullConfiguración específica del tipo
typevarchar(255)nullUno de los siete tipos

Cuatro observaciones que ahorran depuración:

Uno. hidden = true no es un control de acceso. El producto desaparece del listado pero su slug sigue resolviendo. Es útil para planes heredados o precios negociados que compartes por enlace, no para “despublicar” algo. Para eso está status = 'disabled'.

Dos. config es un JSON libre cuyo contenido depende del tipo. Para un producto de hosting, por ejemplo, ahí van server_id y hosting_plan_id; ese detalle se cubre en el capítulo 11.

Tres. addons y upgrades son arrays JSON de IDs guardados en la propia fila del producto, no tablas relacionales. Si borras un producto referenciado, la referencia queda huérfana dentro del JSON.

Cuatro. unit sale impreso en la factura. El default literal es product, y no se traduce solo; si vendes en español, cámbialo.

El modelo de datos completo del catálogo, con las relaciones que importan:

graph TD
    CAT["product_category<br/>id · title"] --> P["product<br/>type · slug · setup · status<br/>stock_control · is_addon"]
    PP["product_payment<br/>type: free once recurrent<br/>columnas por periodo"] --> P
    FORM["form<br/>Formbuilder"] --> P
    FORM --> FF["form_field<br/>text url select radio checkbox textarea"]
    P --> ADD["product.addons<br/>JSON de IDs de addon"]
    P --> UPG["product.upgrades<br/>JSON de IDs destino"]
    ADD --> P2["product con is_addon = true"]
    PROMO["promo<br/>code · type · value<br/>products periods client_groups"] --> P
    PROMO --> PR["promo_redemption<br/>reserved / committed"]
    P --> ORD["client_order<br/>config con los valores del formulario"]
    PR --> INV["invoice<br/>markAsPaid confirma la redencion"]

Categorías de producto: la semilla Default category y su API

Las categorías son la entidad Product/Entity/ProductCategory.php, tabla product_category. La instalación siembra exactamente una fila:

(1, 'Default category', NULL, NULL)

Esa es la categoría a la que va a parar todo producto creado sin product_category_id explícito, y la que aparece como único grupo en la portada del área de cliente hasta que crees otras.

Ruta del panel: /admin/product/category/:id. API, toda bajo el permiso product:manage_products:

EndpointParámetrosUso
product/category_createtitle (requerido)Alta
product/category_updateid + camposEdición
product/category_getidLectura
product/category_get_pairsMapa id => title para desplegables
product/category_deleteidBaja

Desde el rol guest hay dos endpoints públicos equivalentes: product/category_get_list y product/category_get_pairs, que son los que consume el tema del área de cliente para pintar el menú de categorías. Junto con product/get_list, product/get_pairs y product/get, componen toda la superficie pública del catálogo.

Nota de versión: el changelog de 0.8.5 menciona explícitamente fixes SQL en categorías de producto. Si vienes de 0.8.3 o 0.8.4 con listados de categorías que se comportaban raro, la actualización es parte del arreglo.

Los tres modelos de cobro: free, once y recurrent

Los precios viven en una entidad aparte: src/modules/Product/Entity/ProductPayment.php, tabla product_payment, enlazada desde product.product_payment_id. Sus tres constantes:

final public const string FREE      = 'free';
final public const string ONCE      = 'once';
final public const string RECURRENT = 'recurrent';
ModeloColumnas que usaCuándo tiene sentido
freeningunaPlanes gratuitos, pruebas, servicios de cortesía. El pedido se crea igual y pasa por el ciclo de vida normal
onceonce_price, once_setup_priceLicencia perpetua, descargable, trabajo puntual. No genera facturas de renovación
recurrentun trío de columnas por cada periodo habilitadoHosting, suscripciones, cualquier cosa que caduca

Gotcha: al crear un producto, FOSSBilling le asigna automáticamente una fila de ProductPayment de tipo free mediante Product\Service::createDefaultProductPayment(). Es decir, un producto recién creado por API y todavía sin pricing es vendible a coste cero. Si además lo dejas con status = 'enabled' y hidden = false, ya está publicado y regalando servicio. Crea siempre en dos pasos y no habilites hasta haber puesto precio.

La decisión del modelo, con su rama muerta incluida:

flowchart TD
    START["Nuevo producto"] --> Q1{"El servicio caduca<br/>y hay que renovarlo"}
    Q1 -->|No| Q2{"Se cobra algo"}
    Q2 -->|No| FREE["pricing.type = free<br/>getStartingPrice devuelve 0"]
    Q2 -->|Si| ONCE["pricing.type = once<br/>once_price + once_setup_price"]
    Q1 -->|Si| REC["pricing.type = recurrent"]
    REC --> PER{"Que periodos habilitas"}
    PER --> OK["1W 1M 3M 6M 1Y 2Y 3Y<br/>cada uno con price setup enabled"]
    PER --> DEAD["4Y y 5Y"]
    DEAD --> ERR["Box_Period los acepta como periodo de orden<br/>pero product_payment no tiene columna<br/>getProductPaymentPeriodKey lanza excepcion"]
    OK --> MIN["getStartingPrice = minimo de los habilitados"]

Los siete periodos tarificables y el mapa de prefijos de columna

Aquí está la peculiaridad estructural de FOSSBilling: los precios recurrentes no son filas de una tabla de tarifas, son columnas fijas de product_payment. Cada periodo tiene su prefijo y su trío de columnas:

PrefijoCódigo de periodoSignificadoColumnas
w1WSemanalw_price, w_setup_price, w_enabled
m1MMensualm_price, m_setup_price, m_enabled
q3MTrimestralq_price, q_setup_price, q_enabled
b6MSemestralb_price, b_setup_price, b_enabled
a1Y (también acepta 12M)Anuala_price, a_setup_price, a_enabled
bia2YBienalbia_price, bia_setup_price, bia_enabled
tria3YTrienaltria_price, tria_setup_price, tria_enabled

Todas las columnas de precio son DECIMAL(18,2) con default '0.00'. Las *_enabled son BOOLEAN con default true, que es otro detalle a vigilar: en la tabla, un periodo nace habilitado con precio 0.

El mapa vive en Product\Service::getProductPaymentPeriodKey():

'1W'         => 'w',
'1M'         => 'm',
'3M'         => 'q',
'6M'         => 'b',
'12M', '1Y'  => 'a',
'2Y'         => 'bia',
'3Y'         => 'tria',

Cualquier otro código produce:

Unknown period selected {code}

Fíjate en que 1Y y 12M colapsan en el mismo prefijo a. Son el mismo precio, no dos tarifas distintas: no puedes cobrar diferente por “12 meses” y por “1 año”.

Gotcha: el trío DECIMAL(18,2) significa dos decimales, punto. Si vendes en una moneda sin decimales o necesitas precios con tres cifras decimales para conversiones internas, el catálogo va a redondear. Y como el precio se multiplica por la tasa de cambio en Order\Service::createOrder(), el redondeo final depende de la moneda del cliente, no de la columna.

Box_Period: los periodos que existen como pedido pero no se pueden tarificar

La clase src/library/Box/Period.php es la que parsea los códigos de periodo en todo el sistema, y acepta más periodos de los que el catálogo puede tarificar. Un código válido son exactamente dos caracteres, {qty}{unit}, con estos rangos:

UnidadConstanteRango de cantidad
D díaUNIT_DAY1 a 90
W semanaUNIT_WEEK1 a 52
M mesUNIT_MONTH1 a 24
Y añoUNIT_YEAR1 a 5

Y sus tres errores:

Invalid period code. Period definition must be 2 chars length
Period Error. Unit :unit is not defined
Invalid period quantity :qty for unit :unit. Allowed range is from :from to :to

Dentro de ese espacio, Box_Period define 4Y (PERIOD_QUADRENNIAL) y 5Y (PERIOD_QUINQUENNIAL) como periodos perfectamente válidos. Pero product_payment no tiene columnas para ellos. No hay quadria_price ni nada equivalente, y getProductPaymentPeriodKey() lanzaría Unknown period selected 4Y.

La conclusión operativa: un producto no se puede vender a 4 ni a 5 años desde el catálogo estándar. Si te lo piden, tienes dos salidas honestas: crear un pedido a mano con admin/order/create pasando price y period explícitos (el precio no se recalcula del catálogo cuando lo pasas tú), o vender un once y gestionar la caducidad manualmente. El detalle del ciclo de vida de esos pedidos está en el capítulo 7.

Que un rango sea válido tampoco significa que sea vendible por catálogo: 1D es un código legal de Box_Period, pero no hay columna diaria en product_payment. La lista de siete de la sección anterior es la lista completa y cerrada de lo tarificable.

El formulario de precios por dentro: partial_pricing y el array pricing

La pantalla de precios del panel es una plantilla parcial reutilizada: src/themes/admin_default/html/partial_pricing.html.twig, incluida desde mod_product_manage.html.twig. Conocer los nombres de campo te permite automatizar sin abrir el navegador:

  • pricing[type] — radio con valores free, once, recurrent.
  • Pago único: pricing[once][price] y pricing[once][setup].
  • Recurrente, por cada periodo: pricing[recurrent][{1W|1M|3M|6M|1Y|2Y|3Y}][price], [setup] y [enabled] (checkbox con valor 1).

Traducido al JSON que espera product/update:

{
  "id": 3,
  "pricing": {
    "type": "recurrent",
    "recurrent": {
      "1M": { "price": "4.99",  "setup": "0", "enabled": "1" },
      "1Y": { "price": "49.90", "setup": "0", "enabled": "1" },
      "3M": { "price": "0",     "setup": "0", "enabled": "0" }
    }
  }
}

Y lo que devuelve la API al leer un producto (toProductPaymentApiArray()) siempre trae los tres bloques, incluso los que no usas:

{
  "type": "recurrent",
  "free":  { "price": 0, "setup": 0 },
  "once":  { "price": 0, "setup": 0 },
  "recurrent": {
    "1W": {"price": 0,    "setup": 0, "enabled": true},
    "1M": {"price": 4.99, "setup": 0, "enabled": true},
    "3M": {"price": 0,    "setup": 0, "enabled": false},
    "6M": {"price": 0,    "setup": 0, "enabled": true},
    "1Y": {"price": 49.9, "setup": 0, "enabled": true},
    "2Y": {"price": 0,    "setup": 0, "enabled": true},
    "3Y": {"price": 0,    "setup": 0, "enabled": true}
  }
}

Advertencia: mira 2Y y 3Y en esa salida. Están enabled: true con price: 0 porque ese es el default de la columna y nunca los tocaste. Eso significa que el selector de periodo del área de cliente te ofrece un plan bienal gratis. La comprobación obligatoria antes de publicar un producto recurrente es: todo periodo con enabled = true tiene que tener price > 0, o desactívalo explícitamente enviando "enabled": "0".

getStartingPrice y el “desde X” de los listados públicos

Product\Service::getStartingPrice() es la función que alimenta el precio que se ve en la tarjeta del producto antes de entrar al detalle. Su lógica:

  • Tipo free: devuelve 0.
  • Tipo once: devuelve once_price.
  • Tipo recurrent: devuelve el mínimo de los periodos habilitados.

Ese “mínimo” es literal, no normalizado por duración. Si tienes 1M a 4,99 y 1Y a 49,90, el mínimo es 4,99 y el listado muestra “desde 4,99”. Pero si un periodo quedó habilitado a 0,00 por descuido, el mínimo es 0 y tu página de precios anuncia el producto gratis. Es exactamente el fallo de la advertencia anterior, visto desde el escaparate.

Comprobación rápida sobre la base de datos, útil como auditoría del catálogo:

SELECT p.id, p.title, pp.type,
       pp.m_price, pp.m_enabled,
       pp.a_price, pp.a_enabled,
       pp.bia_price, pp.bia_enabled,
       pp.tria_price, pp.tria_enabled
FROM product p
JOIN product_payment pp ON pp.id = p.product_payment_id
WHERE p.status = 'enabled' AND pp.type = 'recurrent';

Cualquier fila con un *_enabled = 1 cuyo *_price = 0.00 es un producto regalado.

Setup fee y los tres momentos de activación: after_order, after_payment y manual

La columna product.setup decide cuándo se aprovisiona el servicio. Sus valores son las constantes Product\Service::SETUP_*:

ValorComportamientoCuándo usarlo
after_orderAprovisiona en el checkout, sin esperar el pagoPruebas gratuitas, planes free, servicios de coste cero para ti
after_paymentDefault. Aprovisiona cuando la factura se marca pagadaLo normal en cualquier cosa que cueste dinero
manualNo se activa nunca solo; un administrador pulsa ActivateProductos que requieren validación previa, servicios con onboarding

Gotcha caro: after_order en un producto de pago significa que cualquiera que complete el formulario de pedido recibe el servicio antes de pagar. Con hosting eso es una fábrica de cuentas de spam. Úsalo solo con productos gratuitos o con clientes de un grupo controlado.

El setup fee es un importe aparte y por periodo: {prefijo}_setup_price para los recurrentes y once_setup_price para el pago único. No es un porcentaje ni un valor global del producto: puedes cobrar 15 de alta en el plan mensual y 0 en el anual, que es la palanca clásica para empujar al contrato largo. Se resuelve con Product\Service::getProductSetupPrice().

Un cupón con freesetup = true anula el setup fee por completo, no lo reduce: se descuenta el importe íntegro.

Control de stock: por qué se descuenta al activar y no al pedir

Dos columnas gobiernan el stock: stock_control (bool) y quantity_in_stock (int). La validación ocurre en dos sitios:

  • En el carrito, Cart\Service::isStockAvailable($product, $qty).
  • Al crear el pedido, dentro de Order\Service::createOrder(), que lanza:
Product :id is out of stock.

Es una InformationException con código 831.

Pero el descuento no ocurre ahí. La reducción real está en la activación: Order\Service::createFromOrder() llama a productService->reduceStock($product_id, $order->quantity) después de que el módulo de servicio haya respondido correctamente.

Ese desfase entre validar y descontar tiene una consecuencia directa que conviene entender antes de vender algo con inventario finito:

flowchart TD
    A["Cliente añade al carrito"] --> B["isStockAvailable lee quantity_in_stock"]
    B --> C["createOrder valida de nuevo<br/>error 831 si no hay"]
    C --> D["Pedido en pending_setup<br/>el stock NO ha bajado"]
    D --> E{"setup del producto"}
    E -->|after_payment| F["Espera a markAsPaid"]
    E -->|after_order| G["Activa de inmediato"]
    E -->|manual| H["Espera al administrador"]
    F --> I["createFromOrder"]
    G --> I
    H --> I
    I --> J["reduceStock descuenta ahora"]
    D --> K["Ventana de sobreventa<br/>varios pedidos pendientes<br/>sobre el mismo stock"]

Advertencia: con setup = after_payment, entre el pedido y el pago pueden pasar días. Durante toda esa ventana el stock sigue intacto y otros clientes pueden pedir lo mismo. FOSSBilling no reserva stock en el pedido. Si vendes unidades genuinamente limitadas (licencias con cupo, hardware), la única mitigación dentro del core es usar setup = after_order y aceptar que se aprovisiona sin cobrar, o revisar a mano los pedidos en pending_setup antes de que el inventario se descuadre.

Addons: is_addon, group_id, group_master y activación en cascada

Un addon no es un tipo de producto: es un Product normal con is_addon = true. Lo que cambia es la interfaz y las reglas de pedido.

Rutas del panel: /admin/product/addons para el listado y /admin/product/addon/:id para editar. API dedicada:

EndpointParámetros
product/addon_createtitle (requerido); opcionales status, setup, icon_url, description
product/addon_updateid + campos
product/addon_getid
product/addon_get_pairs
product/addon_deleteid

La asignación al producto padre se hace en la pestaña Addons de /admin/product/manage/:id, con checkboxes addons[] que guardan los IDs en el JSON product.addons.

Las reglas de pedido son estrictas:

Uno. Un pedido de addon exige el group_id del pedido maestro. Sin él:

Group ID parameter is missing for addon product order   (código 832)

Dos. Si el group_id no corresponde a un pedido maestro de ese cliente:

Parent order :group_id was not found

Tres. El pedido principal lleva group_master = 1 y los addons group_master = 0. Ese flag es también lo que hace que las estadísticas cuadren: Order\Service::counter() filtra por WHERE group_master = 1, así que los addons no se cuentan como pedidos en el dashboard.

Cuatro. Al activar el maestro se activan todos sus addons mediante activateOrderAddons(). Los fallos individuales se registran en el log pero no abortan la activación del maestro: puedes acabar con un hosting activo y su addon de backup en failed_setup sin que nadie te avise en la interfaz. Vigila el log.

Cinco. Al renovar el maestro, si estaba en pending_setup, se renuevan también los addons.

Seis. order/delete acepta delete_addons (bool, default false). Si borras un pedido maestro sin ese flag, los addons quedan huérfanos apuntando a un grupo inexistente.

Siete. Por defecto los addons no se ven en el listado de pedidos. La opción show_addons en la configuración de /admin/order los muestra.

Upgrades sin prorrateo: el flujo real por ticket con rel_task=upgrade

La columna product.upgrades es un JSON con los IDs de los productos a los que un cliente puede subir desde ese producto. Se configura en la pestaña Upgrades de /admin/product/manage/:id, con checkboxes upgrades[].

Lo que sorprende a todo el mundo es lo que pasa después. No existe prorrateo en el core de FOSSBilling 0.8.5. La búsqueda de prorat en todo src/ devuelve cero coincidencias. No hay cálculo de crédito por los días no consumidos del plan viejo, no hay factura de diferencia automática, no hay nada.

El upgrade es un flujo semi-manual mediado por un ticket de soporte:

sequenceDiagram
    autonumber
    participant C as Cliente
    participant A as Area de cliente
    participant S as Ticket de soporte
    participant Adm as Administrador
    C->>A: Abre /order/manage/{id}
    A->>A: client.order_upgradables con el id del pedido
    A-->>C: Lista de productos permitidos desde product.upgrades
    C->>A: Pulsa Request Upgrade
    A->>S: Crea ticket rel_type=order · rel_id=pedido · rel_task=upgrade
    Note over S: rel_new_value = product_id destino<br/>rel_status = pending
    S->>Adm: El ticket aparece con la tarea pendiente
    Adm->>Adm: Cambia el producto y el precio a mano
    Adm->>Adm: Emite la factura de la diferencia si procede
    Adm->>S: support/task_complete
    Note over S: rel_status = complete

Las constantes implicadas están en Support/Entity/SupportTicket.php: REL_TASK_UPGRADE = 'upgrade', REL_TASK_CANCEL = 'cancel', REL_STATUS_PENDING = 'pending', REL_STATUS_COMPLETE = 'complete'.

Las validaciones al crear la petición, en Support\Service:

You must provide both an order ID and a new product ID in order to request an upgrade.
rel_new_value must be a valid positive integer product ID, received: :value
We have already received this request.

Y Product\Service::assertUpgradeAllowedByIds($productoActual, $productoNuevo) comprueba que el destino esté realmente en la lista upgrades del producto de origen. No puedes forzar un upgrade a un producto que no configuraste.

Implicación de negocio: si tu modelo depende de upgrades frecuentes a mitad de ciclo, FOSSBilling te va a costar horas de staff. El ajuste de importe se hace editando el precio del pedido con order/update pasando price (lo que emite una nueva factura por ese importe) o creando una factura custom. La mecánica de esas facturas está en el capítulo 8.

Formularios de pedido con Formbuilder y cuándo el tipo de servicio impone el suyo

Cuando necesitas pedirle datos al cliente en el momento de la compra —el nombre del servidor, el dominio a apuntar, el nombre de la empresa para la licencia— usas el módulo Formbuilder. Sus tablas son form y form_field, y se enlaza al producto con product.form_id (desplegable “Order Form” en la pestaña Settings del producto, que solo aparece si el módulo está activo).

Tipos de campo disponibles, según Formbuilder\Service::getFormFieldsTypes():

'text'     => 'Text Input',
'url'      => 'URL Input',      // añadido en 0.8.0, con validación
'select'   => 'Dropdown',
'radio'    => 'Radio Select',
'checkbox' => 'Checkbox',
'textarea' => 'Text Area',

Los defaults al crear un campo son literales y conviene conocerlos porque llegan rellenos:

  • select, checkbox y radio nacen con options = {"First option":"1","Second option":"2","Third option":"3"}. Si no los editas, tu formulario de pedido pregunta por “First option”.
  • textarea sin opciones nace con options = {"height":"100","width":"300"}.
  • El estilo del formulario es style = {"type": "horizontal", "show_title": "0"}.

La validación del tipo url no es solo filter_var($value, FILTER_VALIDATE_URL): añade un regex que exige TLD sobre el host (/\.[a-zA-Z]{2,}$/). Un http://localhost o un http://servidor-interno serán rechazados.

API del módulo: formbuilder/create_form, add_field, get_form, get_form_fields, get_field, get_forms, delete_form, delete_field, update_field, get_pairs, copy_form, update_form_settings. Desde guest solo se expone formbuilder/get, que es lo que el tema usa para pintar el formulario.

Los valores que envía el cliente se guardan como JSON en client_order.config y se revalidan contra la definición del formulario en Order\Service::updateOrderConfig() mediante validateConfigAgainstForm(). No es un campo libre: si cambias el formulario después de haber vendido, las ediciones posteriores de esos pedidos pueden fallar la validación.

La excepción que te va a confundir: si existe la plantilla mod_service{tipo}_order_form.html.twig para el tipo de producto, el panel no ofrece el selector de Formbuilder y muestra en su lugar un aviso de que ese tipo usa un formulario propio del servicio. Es el caso de servicehosting y servicedomain. Para un plan de hosting no eliges formulario: el módulo impone el suyo, con su campo de dominio y sus reglas.

Promociones: la tabla promo campo a campo

La entidad es src/modules/Product/Entity/Promo.php, tabla promo. Rutas del panel: /admin/product/promos y /admin/product/promo/:id. Permiso: product:manage_promos.

ColumnaTipoDefaultSignificado
codevarchar(100)El cupón que teclea el cliente
descriptiontextNota interna
typevarchar(30)percentagepercentage o absolute (constantes Promo::PERCENTAGE y Promo::ABSOLUTE)
valuedecimal(18,2)nullPorcentaje o importe según type
maxusesint00 = ilimitado
usedint0Contador de usos
freesetupboolfalseAnula el setup fee íntegro
once_per_clientboolfalseUn solo uso por cliente
recurringboolfalsefalse = solo el primer pedido; true = también las renovaciones
activeboolfalseInterruptor
productstext JSONnullIDs de productos a los que aplica
periodstext JSONnullCódigos de periodo permitidos: 1M, 1Y, …
client_groupstext JSONnullGrupos de cliente permitidos
start_at / end_atdatetimenullVentana de validez

Tres defaults que causan tickets de soporte:

Uno. active nace en false. Un cupón recién creado no funciona hasta que lo activas. Es lo correcto, pero es lo contrario de lo que espera casi todo el mundo.

Dos. maxuses = 0 significa ilimitado, no “cero usos”. Si querías un cupón de un solo uso global, pon 1.

Tres. recurring = false es el default, y es la decisión de negocio más importante del cupón: el descuento se aplica solo al primer pedido. Las renovaciones se facturan a precio de tarifa. Si prometiste “20% para siempre”, tienes que marcar recurring = true.

Los client_groups conectan con los grupos de clientes que verás en el capítulo 6. Es el único uso real de los grupos en el core junto con el filtrado de campañas de Massmailer: los grupos no llevan precios ni descuentos propios.

Cálculo del descuento: absoluto sobre la línea, porcentual redondeado

El cálculo está en Product\Service::getProductDiscount() y es corto, pero cada línea importa:

if (!isPromoLinkedToProduct($promo, $product))            return 0;
if (isset($config['period']) && $periods && !in_array($config['period'], $periods)) return 0;
$price = $line['price'] * $line['quantity'];
if ($price == 0) return 0;
switch ($type) {
    case ABSOLUTE:   $discount += (float) $value;                       break;
    case PERCENTAGE: $discount += round($price * $value / 100, 2);      break;
}

Tres consecuencias que hay que tener clarísimas antes de publicar un cupón:

Uno. El descuento absoluto se aplica al total de la línea, es decir a precio × cantidad, no por unidad. Un cupón de 10 sobre un producto de 20 con cantidad 3 (total 60) descuenta 10, no 30. Si tu intención era “10 de descuento por unidad”, el cupón absoluto no lo hace.

Dos. El porcentual se calcula sobre el total de la línea y se redondea a dos decimales con round(). Redondeo aritmético normal, no truncado.

Tres. Si el precio de la línea es 0, el descuento es 0 y sale antes. Un cupón sobre un producto gratuito no hace nada, ni siquiera sobre el setup fee de esa línea.

Aparte del descuento sobre el precio, freesetup = true descuenta el importe íntegro del setup en Cart\Service::getProductDiscount(). Son dos descuentos independientes que se suman.

Y un comportamiento no obvio: un cupón sobrescribe el descuento por items relacionados (getRelatedItemsDiscount()). El comentario en el código es explícito: “Promo discount should override related item discount”. Si tenías un descuento por comprar el addon junto al maestro, el cupón lo reemplaza en vez de acumularse.

Redenciones reserved y committed: el ciclo de vida de un cupón

En 0.8.x el uso de un cupón no es un simple used++. Existe una entidad separada, src/modules/Product/Entity/PromoRedemption.php, con dos estados: STATUS_RESERVED y STATUS_COMMITTED.

stateDiagram-v2
    [*] --> aplicado: cart/apply_promo en el checkout
    aplicado --> reserved: la factura queda unpaid
    aplicado --> committed: el total es 0 y no hay que cobrar
    reserved --> committed: markAsPaid confirma la redencion
    reserved --> liberado: pedido cancelado y reserva liberada
    reserved --> compensado: fallo de checkout compensado
    committed --> [*]: cuenta contra maxuses y once_per_client
    liberado --> [*]
    compensado --> [*]

Traducido a comportamiento observable:

  • Un cliente que aplica el cupón y no paga deja una redención reserved. El cupón está “apartado” para él, pero todavía no consumido de forma definitiva.
  • Cuando la factura se marca pagada, Invoice\Service::markAsPaid() llama a commitReservedPromoRedemptionsForInvoice() y la redención pasa a committed. Si el total era 0, se confirma directamente.
  • Si el pedido se cancela, releaseReservedPromoRedemptionsForOrder($order, 'order_canceled') libera la reserva y el uso vuelve al pool.
  • Si el checkout falla a mitad, compensateCheckoutPromoFailure() deshace la reserva.

El historial es consultable con product/promo_redemption_get_list, que requiere promo_id. Es la forma correcta de auditar quién usó una campaña, en lugar de mirar el contador used.

Los dos errores que el cliente ve en el checkout:

MensajeCódigoCausa
You have already used this promo code. Please remove the promo code and checkout again.9874once_per_client y ya hay una redención de ese cliente
Promo code cannot be applied to your accountEl grupo del cliente no está en promo.client_groups

Gotcha: el primer mensaje aparece también con redenciones reserved de facturas antiguas sin pagar. Si un cliente probó el cupón hace un mes y abandonó el carrito, y luego cancelas ese pedido, la reserva se libera; pero mientras el pedido siga vivo en pending_setup, el cliente seguirá viendo el error 9874.

Alta completa de un producto por API: product/prepare y product/update

El alta es en dos pasos, siempre. No hay un product/create que lo haga todo de golpe.

Paso 1 — product/prepare. Permiso product:manage_products. Devuelve el id del producto recién creado:

API=https://tu-dominio/api
KEY=tu_api_token_de_admin

curl -s -u "admin:$KEY" -X POST "$API/admin/product/prepare" \
  -H 'Content-Type: application/json' \
  -d '{"title":"Hosting Básico","type":"hosting","product_category_id":1}'

Salida esperada:

{"result":3,"error":null}

El slug se genera con Tools::slug($title) y, si colisiona con uno existente, generateUniqueProductSlug() le añade un sufijo -{random 1..9999}. Si te importa la URL, pásale un slug explícito en el paso 2 antes de publicar; si no, acabarás con hosting-basico-4821 indexado por Google.

En este punto el producto ya existe, con status = 'enabled' por default y una fila de precios free creada automáticamente. Está publicado y a coste cero. No lo dejes así.

Paso 2 — product/update. Aquí va todo lo demás: precios, configuración del tipo, momento de activación y visibilidad:

curl -s -u "admin:$KEY" -X POST "$API/admin/product/update" \
  -H 'Content-Type: application/json' \
  -d '{
    "id": 3,
    "title": "Hosting Básico",
    "slug": "hosting-basico",
    "status": "enabled",
    "hidden": false,
    "setup": "after_payment",
    "stock_control": false,
    "product_category_id": 1,
    "unit": "servicio",
    "config": { "server_id": 1, "hosting_plan_id": 2 },
    "pricing": {
      "type": "recurrent",
      "recurrent": {
        "1M": { "price": "4.99",  "setup": "15.00", "enabled": "1" },
        "3M": { "price": "0",     "setup": "0",     "enabled": "0" },
        "6M": { "price": "0",     "setup": "0",     "enabled": "0" },
        "1W": { "price": "0",     "setup": "0",     "enabled": "0" },
        "1Y": { "price": "49.90", "setup": "0",     "enabled": "1" },
        "2Y": { "price": "0",     "setup": "0",     "enabled": "0" },
        "3Y": { "price": "0",     "setup": "0",     "enabled": "0" }
      }
    }
  }'

Fíjate en que envío los siete periodos, incluidos los que no quiero, con "enabled": "0" explícito. Es la forma de neutralizar el default true de las columnas *_enabled. Y en que el setup fee de 15 está solo en el plan mensual: el anual sale sin alta, que es el incentivo para el contrato largo.

Verificación desde el rol guest, que es exactamente lo que ve un visitante:

curl -s "$API/guest/product/get_list?per_page=5"

Si el producto aparece con el starting_price que esperas y el slug correcto, el alta está completa. La referencia completa de la API JSON, la autenticación por HTTP Basic y los hooks disponibles están en el capítulo 14.

Un caso especial que rompe la intuición: para los productos de dominio, el precio de renovación no se toma del pedido. Se recalcula desde el registrador y su configuración en cada renovación, vía Invoice\Service::generateForOrder() y getProductRenewalLineConfig(). Para todos los demás tipos se respeta el precio guardado en el pedido, lo que permite mantener precios heredados editados a mano. Si tenías dominios con precio congelado, la renovación te los va a repreciar.

Checklist antes de publicar un producto

Recorre esto entero cada vez. Casi todos los incidentes de catálogo salen de saltarse un punto:

  • El tipo aparece en admin/product/get_types, no es una constante huérfana.
  • pricing.type no es free por olvido del default de createDefaultProductPayment().
  • Todo periodo con enabled = 1 tiene price > 0.
  • Los periodos que no vendes están enviados explícitamente con enabled = 0.
  • getStartingPrice() en el listado público muestra el importe que esperabas.
  • setup es after_payment salvo que sepas exactamente por qué no.
  • unit está traducido; el default literal es product.
  • Si stock_control = 1, asumes la ventana de sobreventa entre pedido y activación.
  • Los addons asignados existen y están enabled.
  • La lista upgrades apunta a productos vivos.
  • Si hay formulario, no es el de las “First option” por defecto.
  • El slug es el que quieres, no uno con sufijo aleatorio.
  • Los cupones asociados están active = 1 y con recurring correcto.

Errores comunes y diagnóstico

SíntomaCausa probableCómo diagnosticar y arreglar
Product type :type is not registered. (413)El módulo Service{tipo} no está instalado o activoadmin/product/get_types; si querías vps, no hay módulo en el core
You have already created domain product. (413)Ya existe un producto domainSolo se permite uno; los precios por extensión van en la tabla tld
Unknown period selected 4YIntentas tarificar un periodo sin columna en product_paymentSolo 1W 1M 3M 6M 1Y 2Y 3Y son tarificables; 4Y y 5Y existen en Box_Period pero no en el catálogo
Invalid period code. Period definition must be 2 chars lengthEl código de periodo no tiene 2 caracteresFormato {qty}{unit}, por ejemplo 1M
Invalid period quantity :qty for unit :unit...Cantidad fuera de rangoD 1-90, W 1-52, M 1-24, Y 1-5
El producto se vende a 0createDefaultProductPayment() dejó el ProductPayment en free, o un periodo quedó enabled con price = 0.00Consulta SQL de la sección de getStartingPrice; revisa pp.type y cada *_enabled con su *_price
El listado muestra “desde 0”Un periodo habilitado a 0 es el mínimoDesactívalo con "enabled": "0" en product/update
El área de cliente ofrece un plan bienal gratisLos *_enabled nacen en true por defaultEnviar siempre los siete periodos en product/update
Product :id is out of stock. (831)stock_control activo y quantity_in_stock = 0Recuerda que el stock se descuenta en la activación con reduceStock(), no al pedir
Vendiste más unidades de las que teníasVentana entre createOrder() y createFromOrder()No hay reserva de stock en el core; revisa pedidos en pending_setup
Group ID parameter is missing for addon product order (832)Se pidió un addon sin pedido maestroPasar el group_id del pedido con group_master = 1
Parent order :group_id was not foundEl group_id no corresponde a un pedido de ese clienteVerificar client_order.group_id y el propietario
El maestro se activó pero el addon noactivateOrderAddons() registra el fallo en el log sin abortarRevisar el log y activar el addon a mano
Addons huérfanos tras borrar un pedidoorder/delete con delete_addons = false (default)Pasar delete_addons: true
Los addons no aparecen en el listado de pedidosOpción show_addons apagadaActivarla en la configuración de /admin/order
El dashboard cuenta menos pedidos de los que hayOrder\Service::counter() filtra group_master = 1Es correcto: los addons no se cuentan
El upgrade no cobra la diferenciaNo hay prorrateo en el coreFlujo por ticket rel_task=upgrade; ajustar precio con order/update o factura custom
We have already received this request.Ya existe una tarea de upgrade pendiente para ese pedidoCerrar la anterior con support/task_complete
No aparece el selector de Order Form en un producto de hostingExiste mod_service{tipo}_order_form.html.twigEs intencional: servicehosting y servicedomain imponen su formulario
El formulario pregunta por “First option”Defaults de select/radio/checkbox sin editarEditar options del campo
Una URL interna es rechazada en el formularioEl tipo url exige TLD además de FILTER_VALIDATE_URLUsar un host con TLD o cambiar el campo a text
El cupón no hace nadapromo.active nace en falseActivarlo; revisar también start_at / end_at, products y periods
El cupón se aplicó una sola vez y era “para siempre”recurring = false es el defaultPoner recurring = true para que aplique en renovaciones
El descuento absoluto es menor de lo esperado con cantidad > 1Se aplica al total de la línea, no por unidadUsar percentage si quieres que escale con la cantidad
You have already used this promo code... (9874)Hay una redención reserved o committed de ese clienteproduct/promo_redemption_get_list con promo_id; cancelar el pedido pendiente libera la reserva
Promo code cannot be applied to your accountEl grupo del cliente no está en promo.client_groupsRevisar client.client_group_id
El slug del producto tiene un número raro al finalColisión resuelta por generateUniqueProductSlug()Fijar el slug a mano en product/update
El precio de renovación de un dominio no coincide con el pedidoSe recalcula desde el registrador en cada renovaciónEs el comportamiento esperado solo para domain

Lo que queda operativo

Con este capítulo terminado tienes un catálogo real: categorías, productos del tipo correcto con su módulo de servicio detrás, precios recurrentes con los siete periodos bajo control explícito, setup fees por periodo, addons con sus reglas de grupo, upgrades declarados y cupones con su ventana de validez y su segmentación por grupo. También tienes claros los tres límites que no se negocian: nada de 4 ni 5 años tarificables, nada de prorrateo y nada de reserva de stock en el pedido.

Lo que todavía falta es el otro lado del mostrador. Un catálogo sin clientes no vende, y los cupones que acabas de configurar dependen de promo.client_groups, que aún no existe en tu instalación más allá de la semilla Default. En el capítulo 6 montas los grupos de clientes, los hasta veinte campos personalizados, los requisitos del registro, el balance de cuenta con sus créditos y el área de cliente que verá quien compre lo que acabas de publicar.