Catálogo de productos: tipos, precios recurrentes, addons y promociones
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 tipo | Módulo de servicio en el core | Documentado oficialmente | Qué hace realmente |
|---|---|---|---|
hosting | Servicehosting | Sí | Crea la cuenta en cPanel/WHM, Hestia, CWP, Plesk, DirectAdmin |
domain | Servicedomain | Sí | Registra/transfiere el dominio vía Registrar_Adapter |
downloadable | Servicedownloadable | Sí | Entrega un fichero descargable |
license | Servicelicense | Sí | Emite y valida claves de licencia |
custom | Servicecustom | No aparece como “tipo” en la guía | Servicio genérico sin aprovisionamiento automático |
addon | — | No | Marca de rol, no de aprovisionamiento (ver la sección de addons) |
vps | No existe en el core | No | Constante 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.
La regla del producto de dominio único y los errores 413 del catálogo
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:
| Mensaje | HTTP | Dónde se lanza | Causa |
|---|---|---|---|
Product type :type is not registered. | 413 | Product\Api\Admin::prepare() | El módulo Service{tipo} no está instalado o activo |
You have already created domain product. | 413 | Product\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.
| Columna | Tipo | Default | Efecto de negocio |
|---|---|---|---|
product_category_id | int nullable | null | Categoría en el listado público |
product_payment_id | int nullable | null | FK a la fila de precios en product_payment |
form_id | int nullable | null | Formulario de pedido de Formbuilder |
title | varchar(255) | Nombre visible | |
slug | varchar(255) | URL única de la página de pedido | |
description | text | Descripción pública | |
unit | varchar(50) | 'product' | Unidad que se imprime en la línea de factura |
active | bool | true | |
status | varchar(50) | 'enabled' | enabled / disabled |
hidden | bool | false | Oculto de los listados, sigue accesible por enlace directo |
is_addon | bool | false | Convierte el producto en addon |
setup | varchar(50) | 'after_payment' | after_order / after_payment / manual |
addons | text JSON | null | IDs de addons asociados |
icon_url | varchar(255) | null | |
allow_quantity_select | bool | false | El cliente elige cantidad en el carrito |
stock_control | bool | false | Activa el control de existencias |
quantity_in_stock | int | 0 | Existencias restantes |
plugin | varchar | null | Integración con server manager |
plugin_config | text | null | Configuración del plugin |
upgrades | text JSON | null | IDs de productos destino de upgrade |
priority | int | null | Orden de aparición |
config | text JSON | null | Configuración específica del tipo |
type | varchar(255) | null | Uno 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:
| Endpoint | Parámetros | Uso |
|---|---|---|
product/category_create | title (requerido) | Alta |
product/category_update | id + campos | Edición |
product/category_get | id | Lectura |
product/category_get_pairs | — | Mapa id => title para desplegables |
product/category_delete | id | Baja |
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';
| Modelo | Columnas que usa | Cuándo tiene sentido |
|---|---|---|
free | ninguna | Planes gratuitos, pruebas, servicios de cortesía. El pedido se crea igual y pasa por el ciclo de vida normal |
once | once_price, once_setup_price | Licencia perpetua, descargable, trabajo puntual. No genera facturas de renovación |
recurrent | un trío de columnas por cada periodo habilitado | Hosting, 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:
| Prefijo | Código de periodo | Significado | Columnas |
|---|---|---|---|
w | 1W | Semanal | w_price, w_setup_price, w_enabled |
m | 1M | Mensual | m_price, m_setup_price, m_enabled |
q | 3M | Trimestral | q_price, q_setup_price, q_enabled |
b | 6M | Semestral | b_price, b_setup_price, b_enabled |
a | 1Y (también acepta 12M) | Anual | a_price, a_setup_price, a_enabled |
bia | 2Y | Bienal | bia_price, bia_setup_price, bia_enabled |
tria | 3Y | Trienal | tria_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:
| Unidad | Constante | Rango de cantidad |
|---|---|---|
D día | UNIT_DAY | 1 a 90 |
W semana | UNIT_WEEK | 1 a 52 |
M mes | UNIT_MONTH | 1 a 24 |
Y año | UNIT_YEAR | 1 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 valoresfree,once,recurrent.- Pago único:
pricing[once][price]ypricing[once][setup]. - Recurrente, por cada periodo:
pricing[recurrent][{1W|1M|3M|6M|1Y|2Y|3Y}][price],[setup]y[enabled](checkbox con valor1).
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: devuelve0. - Tipo
once: devuelveonce_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_*:
| Valor | Comportamiento | Cuándo usarlo |
|---|---|---|
after_order | Aprovisiona en el checkout, sin esperar el pago | Pruebas gratuitas, planes free, servicios de coste cero para ti |
after_payment | Default. Aprovisiona cuando la factura se marca pagada | Lo normal en cualquier cosa que cueste dinero |
manual | No se activa nunca solo; un administrador pulsa Activate | Productos 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:
| Endpoint | Parámetros |
|---|---|
product/addon_create | title (requerido); opcionales status, setup, icon_url, description |
product/addon_update | id + campos |
product/addon_get | id |
product/addon_get_pairs | — |
product/addon_delete | id |
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,checkboxyradionacen conoptions = {"First option":"1","Second option":"2","Third option":"3"}. Si no los editas, tu formulario de pedido pregunta por “First option”.textareasin opciones nace conoptions = {"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.
| Columna | Tipo | Default | Significado |
|---|---|---|---|
code | varchar(100) | El cupón que teclea el cliente | |
description | text | Nota interna | |
type | varchar(30) | percentage | percentage o absolute (constantes Promo::PERCENTAGE y Promo::ABSOLUTE) |
value | decimal(18,2) | null | Porcentaje o importe según type |
maxuses | int | 0 | 0 = ilimitado |
used | int | 0 | Contador de usos |
freesetup | bool | false | Anula el setup fee íntegro |
once_per_client | bool | false | Un solo uso por cliente |
recurring | bool | false | false = solo el primer pedido; true = también las renovaciones |
active | bool | false | Interruptor |
products | text JSON | null | IDs de productos a los que aplica |
periods | text JSON | null | Códigos de periodo permitidos: 1M, 1Y, … |
client_groups | text JSON | null | Grupos de cliente permitidos |
start_at / end_at | datetime | null | Ventana 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 acommitReservedPromoRedemptionsForInvoice()y la redención pasa acommitted. 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:
| Mensaje | Código | Causa |
|---|---|---|
You have already used this promo code. Please remove the promo code and checkout again. | 9874 | once_per_client y ya hay una redención de ese cliente |
Promo code cannot be applied to your account | — | El 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.typeno esfreepor olvido del default decreateDefaultProductPayment(). - Todo periodo con
enabled = 1tieneprice > 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. -
setupesafter_paymentsalvo que sepas exactamente por qué no. -
unitestá traducido; el default literal esproduct. - Si
stock_control = 1, asumes la ventana de sobreventa entre pedido y activación. - Los addons asignados existen y están
enabled. - La lista
upgradesapunta a productos vivos. - Si hay formulario, no es el de las “First option” por defecto.
- El
sluges el que quieres, no uno con sufijo aleatorio. - Los cupones asociados están
active = 1y conrecurringcorrecto.
Errores comunes y diagnóstico
| Síntoma | Causa probable | Cómo diagnosticar y arreglar |
|---|---|---|
Product type :type is not registered. (413) | El módulo Service{tipo} no está instalado o activo | admin/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 domain | Solo se permite uno; los precios por extensión van en la tabla tld |
Unknown period selected 4Y | Intentas tarificar un periodo sin columna en product_payment | Solo 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 length | El código de periodo no tiene 2 caracteres | Formato {qty}{unit}, por ejemplo 1M |
Invalid period quantity :qty for unit :unit... | Cantidad fuera de rango | D 1-90, W 1-52, M 1-24, Y 1-5 |
| El producto se vende a 0 | createDefaultProductPayment() dejó el ProductPayment en free, o un periodo quedó enabled con price = 0.00 | Consulta 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ínimo | Desactívalo con "enabled": "0" en product/update |
| El área de cliente ofrece un plan bienal gratis | Los *_enabled nacen en true por default | Enviar siempre los siete periodos en product/update |
Product :id is out of stock. (831) | stock_control activo y quantity_in_stock = 0 | Recuerda que el stock se descuenta en la activación con reduceStock(), no al pedir |
| Vendiste más unidades de las que tenías | Ventana 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 maestro | Pasar el group_id del pedido con group_master = 1 |
Parent order :group_id was not found | El group_id no corresponde a un pedido de ese cliente | Verificar client_order.group_id y el propietario |
| El maestro se activó pero el addon no | activateOrderAddons() registra el fallo en el log sin abortar | Revisar el log y activar el addon a mano |
| Addons huérfanos tras borrar un pedido | order/delete con delete_addons = false (default) | Pasar delete_addons: true |
| Los addons no aparecen en el listado de pedidos | Opción show_addons apagada | Activarla en la configuración de /admin/order |
| El dashboard cuenta menos pedidos de los que hay | Order\Service::counter() filtra group_master = 1 | Es correcto: los addons no se cuentan |
| El upgrade no cobra la diferencia | No hay prorrateo en el core | Flujo 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 pedido | Cerrar la anterior con support/task_complete |
| No aparece el selector de Order Form en un producto de hosting | Existe mod_service{tipo}_order_form.html.twig | Es intencional: servicehosting y servicedomain imponen su formulario |
| El formulario pregunta por “First option” | Defaults de select/radio/checkbox sin editar | Editar options del campo |
| Una URL interna es rechazada en el formulario | El tipo url exige TLD además de FILTER_VALIDATE_URL | Usar un host con TLD o cambiar el campo a text |
| El cupón no hace nada | promo.active nace en false | Activarlo; 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 default | Poner recurring = true para que aplique en renovaciones |
| El descuento absoluto es menor de lo esperado con cantidad > 1 | Se aplica al total de la línea, no por unidad | Usar 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 cliente | product/promo_redemption_get_list con promo_id; cancelar el pedido pendiente libera la reserva |
Promo code cannot be applied to your account | El grupo del cliente no está en promo.client_groups | Revisar client.client_group_id |
| El slug del producto tiene un número raro al final | Colisión resuelta por generateUniqueProductSlug() | Fijar el slug a mano en product/update |
| El precio de renovación de un dominio no coincide con el pedido | Se recalcula desde el registrador en cada renovación | Es 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.