Pedidos y ciclo de vida del servicio: del carrito a la suspensión
Pedidos y ciclo de vida del servicio: del carrito a la suspensión
En el capítulo 5 montaste el catálogo: productos, periodos, precios recurrentes, addons y cupones. En el capítulo 6 tienes clientes capaces de entrar al área privada y con moneda asignada. Falta la pieza que une ambas cosas y que es, literalmente, el corazón del negocio: el pedido.
Un pedido en FOSSBilling no es un registro pasivo. Es una máquina de estados con seis posiciones, ocho acciones, cinco columnas de fecha que se mueven solas, dos batches nocturnos que suspenden y cancelan sin preguntar, y un puñado de reglas de transición que devuelven mensajes de error muy concretos cuando las violas. La mayoría de los problemas que la gente reporta como “FOSSBilling no me suspende el servicio” o “el pedido se quedó en pending_setup” son, en realidad, una de esas reglas ejecutándose exactamente como está escrita.
Este capítulo recorre el ciclo completo contra el código de la versión 0.8.5 (tag 0.8.5, publicado el 2026-07-20): qué endpoints toca el carrito, qué hace createOrder() dentro de su transacción de base de datos, cómo se aprovisiona el servicio y qué pasa cuando el módulo de servicio lanza una excepción, cómo se renueva, cuándo se suspende y con qué SQL exacto, y cómo se cancela — incluida la cancelación al final del periodo que llegó con esta misma versión.
Al terminar sabrás leer una fila de client_order y decir, sin abrir el panel, en qué punto del ciclo está y qué la va a mover a continuación.
Ficheros de referencia para todo el capítulo:
src/library/Model/ClientOrder.php
src/modules/Order/Service.php
src/modules/Order/Api/Admin.php
src/modules/Order/templates/admin/mod_order_settings.html.twig
src/modules/Cart/Service.php
src/modules/Cron/Service.php
Rutas del panel: /admin/order, /admin/order/index y /admin/order/manage/:id. En el área de cliente, /order y /order/manage/{id}.
Del carrito al pedido: los ocho endpoints guest del módulo Cart
El carrito de FOSSBilling vive en la sesión y es anónimo: no exige cliente logueado. Toda su superficie pública está en src/modules/Cart/Api/Guest.php y son exactamente ocho métodos:
| Endpoint | Qué hace |
|---|---|
guest/cart/get | Devuelve el carrito actual con sus líneas y totales |
guest/cart/reset | Vacía el carrito |
guest/cart/set_currency | Fija la moneda del carrito |
guest/cart/get_currency | Consulta la moneda activa |
guest/cart/apply_promo | Aplica un cupón |
guest/cart/remove_promo | Quita el cupón aplicado |
guest/cart/add_item | Añade un producto con su periodo y su config |
guest/cart/remove_item | Quita una línea |
Fíjate en lo que no está en esa lista: checkout. El pago no forma parte de la superficie de invitado porque el checkout necesita un cliente al que colgarle el pedido. Si el visitante no tiene sesión, el tema le manda primero a registro o login y después dispara el checkout. Comprueba la firma exacta del método en src/modules/Cart/Api/Client.php de tu instalación si vas a llamarlo desde código propio.
add_item valida dos cosas antes de meter nada: Cart\Service::isStockAvailable($product, $qty) contra stock_control / quantity_in_stock del producto, y los datos de pedido específicos del tipo de servicio. La validación de stock se repite después en createOrder(), así que un carrito viejo con el último artículo agotado falla en el checkout con Product :id is out of stock. (código 831).
Gotcha de rate limiting: apply_promo está limitado por la política cart_promo_apply_ip, de tipo fixed_window, 10 intentos por hora y por IP. Es una defensa contra el barrido de códigos de descuento, y muerde también a los tests automatizados y a las demos en vivo. Se ajusta en config.php bajo rate_limiter.policies, como viste en el capítulo 3. La otra política que te va a afectar en este capítulo es order_generation_ip: fixed_window, 15 pedidos por hora y por IP.
Este es el recorrido completo del checkout, con los métodos internos reales:
sequenceDiagram
autonumber
participant C as Cliente
participant Cart as Cart Service
participant Ord as Order Service
participant Inv as Invoice Service
C->>Cart: guest/cart/add_item
Cart->>Cart: isStockAvailable y validacion del tipo de servicio
C->>Cart: guest/cart/apply_promo
C->>Cart: checkout con gateway_id
Cart->>Ord: createOrder dentro de db transaction
Ord-->>Cart: ClientOrder con status pending_setup y group_master 1
Cart->>Inv: prepareInvoice y setInvoiceDefaults
Inv-->>Cart: serie nr hash due_at taxrate congelados
Cart->>Inv: approveInvoice con use_credits si el saldo cubre el total
Cart->>Ord: order.unpaid_invoice_id apunta a la factura
Cart->>Cart: PromoRedemption en estado reserved
Cart-->>C: redireccion a la pagina de la factura por hash
Dos detalles que importan y que se explican en el capítulo 8: el saldo del cliente solo se usa si cubre el total completo (Cart\Service::createFromCart() pasa use_credits = true únicamente si $balanceAmount >= $ca['total']), y la redención del cupón se crea como reserved, no como consumida. Solo pasa a committed cuando la factura se marca pagada.
createOrder(): parámetros reales, cálculo de precio y transacción de base de datos
Order\Service::createOrder(Model_Client $client, $product, array $data) es el único camino para crear un pedido, lo llame el carrito o lo llame el panel. Su envoltorio de API es admin/order/create, con permiso order:manage, y exige dos parámetros: client_id y product_id.
El resto son opcionales, y esta es la lista completa que acepta:
| Parámetro | Default | Notas |
|---|---|---|
quantity | 1 | Se multiplica en el cálculo de la línea |
price | calculado | Si lo pasas, el catálogo no se consulta |
currency | moneda del cliente, o la de defecto | |
period | ninguno | Código Box_Period: 1M, 1Y, 3Y… |
config | [] | JSON que acaba en client_order.config |
group_id | — | Obligatorio para addons |
activate | false | Activa el pedido en la misma llamada |
invoice_option | — | issue-invoice o no-invoice |
skip_validation | false | Salta la validación específica del tipo de servicio |
title | título del producto | |
notes | — | Notas internas del pedido |
created_at / updated_at | ahora | Para migraciones desde otro billing |
meta | — | Array clave/valor que se escribe en client_order_meta |
mark_invoice_paid | false | Ver la sección siguiente |
El cálculo del precio cuando no pasas price es esta cadena de tres pasos, y conviene tenerla presente porque falla de una forma muy específica:
$line = $productService->getProductOrderLineConfig($product, [...$config, 'quantity' => $qty]);
$rate = $currencyRepository->getRateByCode($currency->getCode()); // null → excepción
$order->price = $line['price'] * $rate;
Si la moneda del pedido existe pero no tiene tasa de conversión configurada, getRateByCode() devuelve null y salta Currency rate for 'XXX' is not configured. No es un error del catálogo: es la moneda. Se arregla ejecutando currency/update_rates o editando la moneda a mano, como se explicó en el capítulo 4.
Toda la creación va dentro de $this->di['db']->transaction(...). El pedido, sus metadatos, la factura y el enlace entre ambos se confirman o se descartan juntos. Con una excepción que hay que conocer: si la generación de la factura falla porque el importe resultante es negativo, se registra el aviso Invoices are not generated for negative amount orders. y el pedido no se revierte. Te queda un pedido sin factura asociada, perfectamente válido, esperando a que alguien facture a mano. Ocurre con cupones absolutos mayores que el precio de la línea.
Los eventos que rodean la creación son onBeforeAdminOrderCreate y onAfterAdminOrderCreate, ambos con el tipo de producto como sujeto del evento, no el pedido.
invoice_option: las dos políticas de facturación que decides al crear el pedido
invoice_option es una columna de client_order y se decide en el momento de crear el pedido. El desplegable del panel se rellena con admin/order/get_invoice_options y solo tiene dos valores:
| Valor | Etiqueta en el panel | Consecuencia |
|---|---|---|
issue-invoice | Automatically Issue Renewal Invoices | El cron genera la factura de renovación antes de que expire |
no-invoice | Issue Invoices Manually | Nunca se genera factura de renovación automática |
Esto no es una preferencia cosmética. El SQL que busca pedidos por renovar (Order\Service::getSoonExpiringActiveOrdersQuery(), usado por invoice_batch_generate) filtra literalmente co.invoice_option = 'issue-invoice'. Un pedido en no-invoice jamás generará su factura de renovación, seguirá corriendo hasta expires_at y luego lo suspenderá el batch de suspensión sin que nadie haya recibido nada que pagar. Es el error de configuración más caro de este capítulo.
mark_invoice_paid = true sirve para cerrar el circuito en una sola llamada (típico de una venta telefónica cobrada por transferencia). Tiene tres requisitos:
Uno. Exige invoice_option = 'issue-invoice'. Si no, devuelve Marking an invoice as paid requires the order to issue an invoice.
Dos. Exige el permiso invoice para el staff que hace la llamada.
Tres. Exige una pasarela válida (gateway_id), porque la factura pagada necesita a qué método de pago atribuirse.
Los seis estados de ClientOrder y las ocho acciones
Las constantes están en src/library/Model/ClientOrder.php y son estas, literales:
final public const string STATUS_PENDING_SETUP = 'pending_setup';
final public const string STATUS_FAILED_SETUP = 'failed_setup';
final public const string STATUS_FAILED_RENEW = 'failed_renew';
final public const string STATUS_ACTIVE = 'active';
final public const string STATUS_CANCELED = 'canceled';
final public const string STATUS_SUSPENDED = 'suspended';
Y las ocho acciones declaradas en el mismo modelo:
ACTION_CREATE, ACTION_ACTIVATE, ACTION_RENEW, ACTION_SUSPEND,
ACTION_UNSUSPEND, ACTION_CANCEL, ACTION_UNCANCEL, ACTION_DELETE
Seis estados, ocho acciones. Nota que create y delete son acciones que no tienen estado propio: una entra al ciclo y la otra saca del ciclo.
Este es el grafo completo, con el método de Order\Service responsable de cada arista:
stateDiagram-v2
[*] --> pending_setup: createOrder
pending_setup --> active: activateOrder correcto
pending_setup --> failed_setup: excepcion en createFromOrder
failed_setup --> active: activateOrder reintento
active --> active: renewOrder correcto
active --> failed_renew: excepcion en renewFromOrder
failed_renew --> active: renewOrder reintento
active --> suspended: suspendFromOrder o batchSuspendExpired
suspended --> active: unsuspendFromOrder
suspended --> canceled: cancelFromOrder o batchCancelSuspended
active --> canceled: cancelFromOrder
canceled --> active: uncancelFromOrder
pending_setup --> [*]: order delete
failed_setup --> [*]: order delete
canceled --> [*]: order delete
Las cinco columnas de fecha que se mueven en estas transiciones son expires_at, activated_at, suspended_at, unsuspended_at y canceled_at. Merece la pena memorizar una: al cancelar se ponen expires_at = null y suspended_at = null. Un pedido cancelado sin fecha de expiración es invisible para el batch de suspensión, que es exactamente lo que quieres, pero también significa que has perdido el dato de cuándo caducaba. Si necesitas esa fecha para reporting, cópiala a client_order_meta antes de cancelar.
La inconsistencia de failed_renew en el desplegable de estados
Aquí hay un detalle real del código de 0.8.5 que confunde a todo el mundo la primera vez.
admin/order/get_status_pairs — el endpoint que rellena el filtro de estados del listado de pedidos — devuelve cinco etiquetas: pending_setup, failed_setup, active, suspended y canceled. failed_renew no está.
Pero el estado existe (constante STATUS_FAILED_RENEW), se escribe en la base de datos cuando una renovación falla, y sí lo cuenta Order\Service::counter(), que es lo que alimenta order/get_statuses y los contadores del dashboard.
La consecuencia práctica: puedes tener pedidos en failed_renew que no aparecen si filtras por estado en el panel, porque no hay opción que seleccionar. La forma fiable de encontrarlos es SQL directo:
SELECT id, client_id, title, expires_at, updated_at
FROM client_order
WHERE status = 'failed_renew'
ORDER BY updated_at DESC;
O por API, pasando el estado a mano al listado:
curl -u 'admin:TU_API_TOKEN' \
-H 'Content-Type: application/json' \
-d '{"status":"failed_renew","per_page":100}' \
https://tu-dominio/api/admin/order/get_list
Es una inconsistencia del propio código, no un fallo de tu instalación. Verifica si sigue presente en tu versión mirando el array que devuelve get_status_pairs en src/modules/Order/Api/Admin.php.
Reglas exactas de transición y los mensajes de error que las delatan
Cada método de transición valida el estado de origen antes de tocar nada. Esta tabla es la referencia completa, con el mensaje de error literal que devuelve cuando la validación falla:
| Método | Estados de origen permitidos | Mensaje de error |
|---|---|---|
activateOrder() | pending_setup, failed_setup | Only pending setup or failed orders can be activated |
suspendFromOrder() | active | Only active orders can be suspended |
unsuspendFromOrder() | suspended | Only suspended orders can be unsuspended |
uncancelFromOrder() | canceled | Only canceled orders can be uncanceled |
cancelFromOrder() | todos menos canceled, pending_setup y failed_setup | Cannot cancel {status} order |
| Borrado desde área de cliente | pending_setup, failed_setup | Only pending and failed setup orders can be deleted. |
Cuatro matices que no se leen en la tabla:
Uno. activateOrder() acepta force = true para saltarse la comprobación de estado. Es la vía para reaprovisionar un servicio ya activo — útil cuando el servidor perdió la cuenta pero FOSSBilling cree que existe. Úsalo sabiendo que va a llamar otra vez al módulo de servicio.
Dos. Si el pedido ya está active y no pasas force, activateOrder() devuelve true sin hacer nada. No lanza error. Esto es importante para scripts: un “OK” no significa que se haya aprovisionado ahora.
Tres. order/renew sobre un pedido en pending_setup o failed_setup no renueva: redirige internamente a activate. Tiene sentido — no puedes extender la vigencia de algo que nunca llegó a existir — pero explica por qué a veces “renovar” acaba creando la cuenta en el servidor.
Cuatro. Un pedido en pending_setup no se puede cancelar; se borra. Es la respuesta al error Cannot cancel pending_setup order, que aparece constantemente en foros: no busques el botón de cancelar, busca el de eliminar.
client_order_status: el historial de cambios y cómo anotarlo
Cada transición deja una fila en la tabla client_order_status a través de Order\Service::saveStatusChange(). Esa fila es lo que ves en la pestaña de historial de /admin/order/manage/:id, y contiene el estado resultante más una nota de texto. Los mensajes de error de aprovisionamiento acaban ahí, no solo en el log.
Tres endpoints lo gobiernan:
| Endpoint | Uso |
|---|---|
admin/order/status_history_get_list | Lee el historial de un pedido |
admin/order/status_history_add | Añade una entrada a mano |
admin/order/status_history_delete | Borra una entrada |
status_history_add es más útil de lo que parece. Cuando resuelves una incidencia fuera de FOSSBilling — migraste la cuenta a otro servidor, el cliente pidió una prórroga por teléfono — anótalo ahí y queda pegado al pedido, no perdido en un ticket:
curl -u 'admin:TU_API_TOKEN' \
-H 'Content-Type: application/json' \
-d '{"id":128,"status":"active","notes":"Cuenta migrada al servidor web-03 por incidencia de disco"}' \
https://tu-dominio/api/admin/order/status_history_add
Comprueba en tu versión los nombres exactos de los parámetros que acepta ese método en src/modules/Order/Api/Admin.php; el conjunto mínimo verificado es el identificador del pedido más el estado a registrar.
Activación y aprovisionamiento: _callOnService y qué pasa cuando falla
Order\Service::createFromOrder() es el punto donde FOSSBilling deja de ser un gestor de facturas y se convierte en un aprovisionador. Hace dos llamadas al módulo de servicio correspondiente al tipo de producto:
$this->_callOnService($order, 'create'); // si el servicio no existe todavía
$this->_callOnService($order, 'activate');
_callOnService() resuelve el módulo por el tipo del producto: un producto hosting va a Servicehosting, uno domain a Servicedomain, y así. Es la razón por la que un producto de un tipo sin módulo instalado falla con Product type :type is not registered. En el capítulo 11 verás cómo se escribe un módulo de servicio propio que responda a estas llamadas.
sequenceDiagram
participant O as Order Service
participant M as Modulo Service del tipo
participant DB as Base de datos
O->>M: _callOnService con create
M-->>O: objeto de servicio creado
O->>DB: order.service_id apunta al servicio
O->>M: _callOnService con activate
alt El servicio lanza excepcion
O->>DB: order.status pasa a failed_setup
O->>DB: saveStatusChange con el mensaje de error
O-->>O: relanza la excepcion hacia arriba
else Todo correcto
O->>DB: expires_at desde period getExpirationTime
O->>DB: status active y activated_at ahora
O->>DB: suspended_at nulo y canceled_at nulo
O->>DB: productService reduceStock del producto
O->>DB: saveStatusChange con Order activated
O->>O: activateOrderAddons del grupo
end
Lo que hay que retener de la rama de error: el pedido queda en failed_setup con el mensaje del módulo de servicio escrito en el historial, y la excepción se relanza. Esto último importa porque si la activación venía de invoice_batch_activate_paid en el cron, el fallo se propaga y queda registrado en el canal cron. El cliente ya pagó y el servicio no existe: failed_setup con factura pagada es la cola de trabajo prioritaria de cualquier operación real.
De la rama correcta hay dos efectos secundarios fáciles de olvidar:
Uno. expires_at se calcula con period->getExpirationTime() partiendo de expires_at si ya había uno, o de ahora si estaba a null. Un pedido activado dos veces no duplica su vigencia si ya tenía fecha.
Dos. El stock se descuenta aquí, con productService->reduceStock($product_id, $order->quantity), no al hacer el pedido. Diez pedidos pendientes de pago sobre una unidad en stock son diez pedidos válidos; el noveno que active fallará.
Emails de servicio: la plantilla que se compone por nombre de tipo
El aviso al cliente no lo manda createFromOrder(). Lo manda un listener estático, Order\Service::onAfterAdminOrderActivate(), que compone el código de la plantilla así:
sprintf('mod_service%s_activated', $orderArr['service_type'])
O sea que un producto hosting dispara mod_servicehosting_activated, uno license dispara mod_servicelicense_activated, y un tipo aportado por una extensión dispara mod_service{lo-que-sea}_activated. El patrón general es mod_service{tipo}_{activated|renewed|suspended|unsuspended|canceled}.
Los fallos de envío se loguean en el canal email y no rompen la activación. Es la decisión correcta —no quieres que un SMTP caído deje el servicio sin aprovisionar— pero significa que “el cliente no recibió el correo” nunca aparecerá como un error del pedido. Se busca en el historial de emails y en el canal email de los logs.
Gotcha de plantillas que faltan: la cobertura del core no es uniforme. servicedomain no tiene plantilla _canceled, y servicedownloadable solo tiene _activated. Si tu flujo depende de avisar al cliente cuando se cancela un dominio o se suspende una descarga, esas plantillas hay que crearlas como plantillas custom desde /admin/email/templates. Antes de prometer un aviso, comprueba que existe:
SELECT action_code, enabled FROM email_template
WHERE action_code LIKE 'mod_service%'
ORDER BY action_code;
Renovación: las tres políticas de order_renewal_logic
renewFromOrder() recalcula expires_at, y cómo lo hace depende de un ajuste global del módulo Order: order_renewal_logic, en /admin/order → ajustes (config mod_order).
| Valor | Cálculo de la nueva fecha |
|---|---|
from_expiration_date | Desde expires_at actual, o desde ahora si es null. Es el default |
from_today | Desde ahora, siempre |
from_greater | Desde expires_at si aún está en el futuro; si ya pasó, desde ahora |
La diferencia se nota en el caso del cliente que paga tarde. Con from_expiration_date, el cliente que renueva quince días después de expirar recibe el mes que ya había consumido: la nueva fecha sale de la vieja. Con from_today pierde esos quince días. from_greater es el punto medio y suele ser lo que la gente cree que está configurado por defecto: no lo es.
El resultado de la renovación es simétrico al de la activación:
- Si el servicio lanza excepción: el pedido pasa a
failed_renew, se anota el error en el historial y se relanza la excepción. - Si va bien:
status = active, ysuspended_at,unsuspended_atycanceled_atse ponen anull, con la notaOrder reneweden el historial.
Esa limpieza de suspended_at es la que permite el flujo de rescate: un pedido suspendido que se paga se desuspende y se renueva, y sale del ciclo de suspensión con las fechas limpias.
Grupos de pedidos: maestro, addons y renovación en cascada
Los addons no son líneas de un pedido: son pedidos completos agrupados con el principal por la columna group_id. El pedido principal lleva group_master = 1 y los addons group_master = 0.
Reglas verificadas:
- Un pedido de addon exige
group_id. Sin él:Group ID parameter is missing for addon product order(código 832). - Si el
group_idno corresponde a un pedido maestro de ese mismo cliente:Parent order :group_id was not found. - Al activar el maestro,
activateOrderAddons()activa todos sus addons. Los fallos individuales se registran en el log pero no abortan la activación del maestro. Un addon puede quedarse enfailed_setupmientras el maestro estáactive, y nadie te avisa. - Al renovar el maestro, si estaba en
pending_setup, se renuevan también los addons. order/deleteaceptadelete_addons(bool, defaultfalse). Por defecto, borrar el maestro deja los addons huérfanos.- La visibilidad de los addons en el listado del panel la controla la opción
show_addonsde/admin/order, apagada por defecto.
Y una consecuencia de reporting que sorprende: Order\Service::counter(), que alimenta los contadores por estado del dashboard, filtra WHERE group_master = 1. Los addons no se cuentan. Si vendes muchos addons, el número de “pedidos activos” del dashboard es sistemáticamente menor que SELECT COUNT(*) FROM client_order WHERE status='active'. No es un bug; es el filtro.
Suspensión automática: el SQL de batchSuspendExpired y sus ajustes
El batch de suspensión se ejecuta desde el cron como la tarea order_batch_suspend_expired. El despachador de la API parte el nombre por el primer guion bajo, así que corresponde al método batch_suspend_expired del módulo order; puedes lanzarlo a mano con admin/order/batch_suspend_expired.
Su criterio de selección es este, y no hay más:
SELECT * FROM client_order
WHERE status = 'active'
AND expires_at IS NOT NULL
AND expires_at <= NOW()
ORDER BY id
Tres consecuencias directas:
Uno. Lo que dispara la suspensión es client_order.expires_at, no el due_at de la factura. Una factura vencida hace un mes no suspende nada si el pedido todavía tiene vigencia. Y al revés: un pedido cuyo expires_at pasó se suspende aunque la factura esté pagada, si por lo que sea la renovación no se ejecutó.
Dos. Un pedido con expires_at IS NULL — típicamente un producto de pago único sin periodo — nunca se suspende automáticamente.
Tres. El batch captura la excepción de cada pedido y continúa con el siguiente. Un servidor de hosting caído no bloquea la suspensión de los demás; simplemente ese pedido se queda active y el error va al log.
Los ajustes viven en /admin/order (config mod_order):
| Clave | Etiqueta en el panel | Default |
|---|---|---|
order_renewal_logic | Renewal Logic | from_expiration_date |
show_addons | Order Visibility | apagado |
batch_suspend_reason | Auto Suspend Reason | vacío |
batch_cancel_suspended | Auto Cancellation Policy | 1 |
batch_cancel_suspended_after_days | Cancel After Suspension | 7 |
batch_cancel_suspended_reason | Required Suspension Reason | vacío |
suspend_reason_list | Suspension Reasons | vacío |
suspend_reason_list es un textarea con una razón por línea; alimenta el desplegable que el staff ve al suspender a mano. batch_suspend_reason es el motivo que se estampa en las suspensiones automáticas: rellénalo con algo que el cliente pueda entender, porque acaba en el historial del pedido.
Cancelación automática tras la suspensión y el plazo por defecto
La segunda mitad del ciclo de impago es order_batch_cancel_suspended, y solo se ejecuta si batch_cancel_suspended está activo — lo está por defecto.
SELECT id, suspended_at, DATEDIFF(NOW(), suspended_at) AS days_passed_since_suspension
FROM client_order
WHERE status = 'suspended'
AND DATEDIFF(NOW(), suspended_at) > :days
ORDER BY id DESC
Con :days = batch_cancel_suspended_after_days, cuyo default es 7. Es decir: de fábrica, FOSSBilling cancela definitivamente cualquier pedido que lleve más de siete días suspendido. Igual que el batch anterior, captura la excepción por pedido y continúa.
Al cancelar de verdad se ejecuta productService->releaseReservedPromoRedemptionsForOrder($order, 'order_canceled'), que libera las redenciones de cupón que estaban en estado reserved. Un cupón de uso único bloqueado por un pedido que nunca se pagó vuelve a estar disponible.
Este es el recorrido completo del impago, con la rama de rescate:
flowchart TD
A["Factura de renovacion emitida y no pagada"] --> B["El pedido sigue active"]
B --> C{"expires_at ya paso"}
C -- No --> B
C -- Si --> D["order_batch_suspend_expired"]
D --> E["status suspended y suspended_at con la fecha de hoy"]
E --> F{"El cliente paga tarde"}
F -- Si --> G["invoice_batch_activate_paid ejecuta la tarea renew"]
G --> H["unsuspendFromOrder y despues renewOrder"]
H --> I["status active con expires_at recalculado"]
F -- No --> J{"Dias suspendido mayores que batch_cancel_suspended_after_days"}
J -- No --> F
J -- Si --> K["order_batch_cancel_suspended"]
K --> L["status canceled con expires_at nulo y suspended_at nulo"]
L --> M["releaseReservedPromoRedemptionsForOrder libera los cupones"]
Advertencia sobre los siete días: ese plazo se cuenta con DATEDIFF sobre suspended_at, que es una diferencia de días de calendario, no de horas. Y como la cancelación pone expires_at = null, después de que el batch pase, reactivar al cliente ya no es “quitar la suspensión”: es uncancelFromOrder() seguido de una renovación con fecha nueva. Si tu política comercial es más laxa que una semana, cambia batch_cancel_suspended_after_days antes de abrir al público.
Cancelación al final del periodo (0.8.5) y su requisito de pasarela
Hasta 0.8.4 cancelar era inmediato: el cliente perdía el servicio en el acto aunque hubiera pagado el mes completo. La versión 0.8.5 añadió la cancelación diferida.
admin/order/cancel acepta ahora cancel_at_period_end = true. Antes de ofrecerlo en la interfaz, admin/order/can_cancel_at_period_end te dice si ese pedido concreto lo soporta. Si no lo soporta:
No active gateway subscription that supports cancellation at period end is linked to this order.
El requisito es explícito: hace falta una suscripción activa de pasarela que implemente la cancelación diferida. En la capa de adaptadores de pago eso se traduce en el método opcional cancelSubscriptionAtPeriodEnd(string $subscriptionId): void, que Stripe implementa y la mayoría de adaptadores no. Un pedido pagado por transferencia manual, o con una pasarela sin suscripciones, no puede cancelarse al final del periodo: solo inmediatamente. Los detalles del contrato de adaptadores están en el capítulo 10.
Mecánica interna:
- Se guarda
META_CANCEL_AT_PERIOD_ENDenclient_order_meta. - Se añade al historial la nota
Cancellation scheduled at the end of the current billing period. - El pedido sigue
activey el servicio sigue funcionando. - Cuando la pasarela confirma que el periodo terminó,
finalizeCancellationFromGateway()completa la cancelación de verdad.
El punto 3 es el que hay que explicar al equipo de soporte: un pedido con cancelación programada se ve exactamente igual que uno normal en el listado. La única evidencia está en el meta y en el historial de estados.
Borrar frente a cancelar: qué permite el área de cliente
Son operaciones distintas y el área de cliente solo expone una de ellas de forma limitada.
| Cancelar | Borrar | |
|---|---|---|
| Qué hace | status = canceled, canceled_at = now, expires_at = null, suspended_at = null | Elimina la fila de client_order |
| Estados de origen | Todos menos canceled, pending_setup, failed_setup | Cualquiera desde el panel |
| Desde el área de cliente | Vía ticket con rel_task = cancel | Solo pending_setup y failed_setup |
| Reversible | Sí, con uncancelFromOrder() | No |
| Historial | Se conserva | Desaparece |
El mensaje que ve el cliente si intenta borrar cualquier otra cosa es Only pending and failed setup orders can be deleted. Tiene lógica: un pedido que llegó a activarse tiene facturas colgando y su borrado rompería el histórico contable.
Para las bajas reales, el flujo del core es el mismo que el de los upgrades: el cliente abre un ticket con rel_type = order, rel_task = cancel y rel_status = pending, y un administrador lo ejecuta y cierra la tarea con support/task_complete. No hay autoservicio de cancelación en el core; lo más cercano es la cancelación al final del periodo de la sección anterior, y esa la dispara el staff o la pasarela.
Al borrar un pedido maestro recuerda delete_addons: sin ese flag los addons sobreviven al padre.
Alta de pedido desde el panel: la venta telefónica en una sola llamada
Todo lo anterior se puede encadenar en una sola llamada a admin/order/create. Es el caso de la venta cerrada por teléfono y cobrada por transferencia:
curl -u 'admin:TU_API_TOKEN' \
-H 'Content-Type: application/json' \
-d '{
"client_id": 42,
"product_id": 3,
"period": "1Y",
"quantity": 1,
"invoice_option": "issue-invoice",
"activate": true,
"mark_invoice_paid": true,
"gateway_id": 1,
"transactionId": "TRANSF-2026-0812",
"config": { "domain": "ejemplo.com" }
}' \
https://tu-dominio/api/admin/order/create
La secuencia interna que dispara es:
createOrder()dentro de la transacción → pedido enpending_setup.generateForOrder()→ proforma con la serie y eldue_atcongelados.approveInvoice(use_credits = true)→ aplica saldo si lo hay.markAsPaidByAdmin()→ la factura pasa apaidy se reasigna la serie de pagadas.activateOrder()→ aprovisionamiento ystatus = active.
Gotcha: si el seguimiento de la factura falla en cualquiera de los pasos 2 a 4, se añade al pedido la nota Order was created, but invoice follow-up failed: {mensaje} y el pedido se conserva. No es una operación atómica de extremo a extremo: solo createOrder() lo es. Después de una llamada así, siempre comprueba que el pedido tiene factura pagada asociada y no solo que devolvió un ID.
Para migraciones desde otro sistema de facturación, los parámetros created_at, updated_at y meta son los que te permiten conservar fechas originales e identificadores externos. Y skip_validation salta la validación específica del tipo de servicio, imprescindible cuando importas dominios ya registrados que no pasarían el chequeo de disponibilidad.
Los eventos de pedido disponibles para automatizar
Todo el ciclo emite eventos, y el sistema de hooks de FOSSBilling los persiste en base de datos. Un módulo propio con un método public static function onAfterAdminOrderSuspend(\Box_Event $event) en su Service.php los recibe. El mecanismo completo está en el capítulo 14; aquí va el catálogo.
Pedidos, en pares onBefore / onAfter:
onBeforeAdminOrderCreate onAfterAdminOrderCreate
onBeforeAdminOrderActivate onAfterAdminOrderActivate
onBeforeAdminOrderRenew onAfterAdminOrderRenew
onBeforeAdminOrderSuspend onAfterAdminOrderSuspend
onBeforeAdminOrderUnsuspend onAfterAdminOrderUnsuspend
onBeforeAdminOrderCancel onAfterAdminOrderCancel
onBeforeAdminOrderUncancel onAfterAdminOrderUncancel
onBeforeAdminOrderUpdate onAfterAdminOrderUpdate
onBeforeAdminOrderDelete onAfterAdminOrderDelete
Batches:
onBeforeAdminBatchSuspendOrders onAfterAdminBatchSuspendOrders
onBeforeAdminBatchCancelSuspendedOrders onAfterAdminBatchCancelSuspendedOrders
onBeforeAdminBatchSendSuspensionWarnings onAfterAdminBatchSendSuspensionWarnings
Lado cliente, durante el checkout:
onBeforeProductAddedToCart onAfterProductAddedToCart
onBeforeClientCheckout onAfterClientOrderCreate
Dos reglas de oro con estos hooks: un onBefore* puede abortar la operación lanzando una excepción (usa FOSSBilling\InformationException para que el mensaje sea visible al usuario), y un onAfter* nunca debe dejar escapar una excepción, porque romperías el checkout del cliente. Envuelve el cuerpo entero en try/catch.
Recuerda además el descubrimiento diferido: los listeners se registran cuando corre hook_batch_connect, que es la primera tarea del cron. Si añades un hook nuevo y no se dispara, ejecuta php src/cron.php una vez y comprueba la tabla:
SELECT rel_id, meta_value FROM extension_meta
WHERE extension = 'mod_hook' AND rel_type = 'mod' AND meta_key = 'listener'
ORDER BY rel_id, meta_value;
Sobre los eventos de aviso de suspensión: los pares onBeforeAdminBatchSendSuspensionWarnings / onAfterAdminBatchSendSuspensionWarnings existen en el catálogo de eventos, pero la tarea que los dispararía no está en la lista de tareas de cron de 0.8.5. Antes de construir nada encima, comprueba en tu instalación qué invoca realmente runCrons():
grep -n "order_batch" src/modules/Cron/Service.php
El orden del cron y por qué importa
Nada de este capítulo pasa sin cron. Cron\Service::runCrons() ejecuta las tareas en este orden exacto:
hook_batch_connect
evento onBeforeAdminCronRun
invoice_batch_pay_with_credits <- no aislada: si falla, aborta el resto
invoice_batch_activate_paid <- no aislada
--- tareas aisladas, un fallo no tumba a las demas ---
invoice_batch_generate
invoice_batch_send_reminders
invoice_batch_invoke_due_event
order_batch_suspend_expired
order_batch_cancel_suspended
support_batch_ticket_auto_close
client_batch_expire_password_reminders
cart_batch_expire
email_batch_sendmail
--- fin ---
setParamValue last_cron_exec
clearOldSessions
evento onAfterAdminCronRun
El orden no es arbitrario y explica varios comportamientos:
Uno. invoice_batch_activate_paid corre antes que order_batch_suspend_expired. Un cliente que pagó justo antes de la ejecución del cron ve su servicio activado en la misma pasada en que se habría suspendido. Con un cron cada cinco minutos, la ventana de suspensión indebida es prácticamente nula.
Dos. invoice_batch_pay_with_credits y invoice_batch_activate_paid no están aisladas: si una lanza, el resto de la ejecución no corre. Un fallo ahí paraliza también las suspensiones de ese ciclo.
Tres. email_batch_sendmail es la última. Los correos de activación, suspensión y cancelación generados durante la ejecución salen al final, en la misma pasada, siempre que queue_once del módulo Email sea mayor que cero. Con queue_once = 0 la cola no se vacía nunca.
Desde 0.8.5, php src/cron.php devuelve exit code 1 si alguna tarea aislada falló, lo que lo hace monitorizable con cualquier supervisor.
Diagnóstico: el pedido no se activa, no renueva o no suspende
Los tres fallos que más se reportan tienen causas distintas y se diagnostican en sitios distintos. Esta tabla reúne esos tres y el resto de errores comunes del ciclo de vida, con el mensaje literal cuando existe.
| Síntoma | Causa probable | Cómo diagnosticar o arreglar |
|---|---|---|
El pedido se queda en pending_setup tras pagar | product.setup = manual, o el cron no corre, o invoice_batch_activate_paid abortó | Ejecuta php src/cron.php a mano y mira el canal cron; revisa setup del producto |
El pedido está en failed_setup con factura pagada | El módulo de servicio lanzó excepción al aprovisionar | El mensaje exacto está en client_order_status; corrige la causa y usa order/activate |
Only pending setup or failed orders can be activated | El pedido ya está active | Usa force = true solo si quieres reaprovisionar de verdad |
activateOrder devuelve OK pero no pasa nada | Ya estaba active y no se pasó force: devuelve true sin actuar | Comprueba activated_at antes y después |
Cannot cancel pending_setup order | Ese estado no admite cancelación | Bórralo en lugar de cancelarlo |
| No se generan facturas de renovación | invoice_option = 'no-invoice', o falta period, o falta expires_at, o ya hay una impagada | SELECT status, invoice_option, period, expires_at, unpaid_invoice_id FROM client_order WHERE id = X |
| La factura vencida no suspende el servicio | Lo que dispara la suspensión es client_order.expires_at, no invoice.due_at | Revisa expires_at del pedido, no la factura |
| Un pedido nunca se suspende | expires_at IS NULL o el estado no es active | Los pedidos sin fecha de expiración quedan fuera del SQL del batch |
| Se cancelaron pedidos que no debían | batch_cancel_suspended = 1 y batch_cancel_suspended_after_days = 7 por defecto | Ajusta el plazo o apaga la política en /admin/order |
Un pedido en failed_renew no aparece en el filtro | get_status_pairs omite ese estado en 0.8.5 | Búscalo por SQL o pasando status a order/get_list |
| Los contadores del dashboard no cuadran con la base de datos | Order\Service::counter() filtra group_master = 1 | Los addons no se cuentan; compara con group_master = 1 en tu consulta |
| Un addon quedó sin activar y el maestro está activo | activateOrderAddons() registra los fallos individuales pero no aborta | Filtra por group_id y revisa el estado de cada línea |
| Al borrar el pedido maestro quedaron addons sueltos | delete_addons es false por defecto | Pasa delete_addons: true en order/delete |
| El cliente no recibe el correo de activación | Fallo de envío logueado en el canal email, o plantilla inexistente para ese tipo | Verifica que existe mod_service{tipo}_activated y que queue_once es mayor que cero |
| No hay aviso al cancelar un dominio | servicedomain no trae plantilla _canceled | Créala como plantilla custom |
Currency rate for 'XXX' is not configured | La moneda del pedido no tiene tasa | currency/update_rates o edita la moneda |
Product :id is out of stock. (831) | stock_control activo y sin unidades | El stock se descuenta al activar, no al pedir |
Group ID parameter is missing for addon product order (832) | Addon sin pedido maestro | Pasa el group_id del maestro |
No active gateway subscription that supports cancellation at period end... | La pasarela no implementa la cancelación diferida | Cancela de forma inmediata o migra el pedido a una suscripción compatible |
| HTTP 429 al probar cupones o crear pedidos en pruebas | cart_promo_apply_ip 10/hora, order_generation_ip 15/hora | Añade tu IP a rate_limiter.whitelist_ips en config.php |
| El hook de pedido no se ejecuta nunca | Los listeners se registran en hook_batch_connect, primera tarea del cron | Corre el cron y revisa extension_meta |
Consulta de auditoría rápida
Esta consulta te da, de una vez, la foto del ciclo de vida de toda la cartera:
SELECT status,
COUNT(*) AS pedidos,
SUM(expires_at IS NULL) AS sin_expiracion,
SUM(invoice_option = 'no-invoice') AS sin_factura_automatica,
SUM(group_master = 0) AS addons
FROM client_order
GROUP BY status
ORDER BY pedidos DESC;
Las tres columnas de la derecha son las que delatan los problemas silenciosos: pedidos activos sin expires_at que nunca caducarán, pedidos en no-invoice que nunca generarán cobro, y el volumen de addons que tu dashboard no está contando.
Lo que queda operativo y qué sigue
Con este capítulo tienes el ciclo completo bajo control: sabes qué endpoints toca el carrito y qué límites de tasa los protegen, qué parámetros acepta createOrder() y qué parte de su trabajo es realmente transaccional, las seis posiciones de la máquina de estados con las reglas exactas que gobiernan cada arista, cómo se aprovisiona un servicio y qué queda escrito cuando falla, las tres políticas de renovación, los dos batches del cron con su SQL literal, y la cancelación al final del periodo que introdujo 0.8.5 junto a su requisito de pasarela.
Queda una mitad del negocio sin cubrir: el dinero. Los pedidos generan facturas, las facturas se pagan con transacciones, los pagos parciales dejan saldo y las bajas exigen reembolsos o notas de crédito. Ese es el terreno del capítulo 8: numeración y series, invoice_batch_generate en detalle, recordatorios, créditos del cliente y las tres políticas de invoice_refund_logic.