Pedidos y ciclo de vida del servicio: del carrito a la suspensión

Por: Artiko
fossbillingpedidoscarritocheckoutaprovisionamientorenovacionsuspensioncancelacioncroneventosciclo-de-vida

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:

EndpointQué hace
guest/cart/getDevuelve el carrito actual con sus líneas y totales
guest/cart/resetVacía el carrito
guest/cart/set_currencyFija la moneda del carrito
guest/cart/get_currencyConsulta la moneda activa
guest/cart/apply_promoAplica un cupón
guest/cart/remove_promoQuita el cupón aplicado
guest/cart/add_itemAñade un producto con su periodo y su config
guest/cart/remove_itemQuita 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ámetroDefaultNotas
quantity1Se multiplica en el cálculo de la línea
pricecalculadoSi lo pasas, el catálogo no se consulta
currencymoneda del cliente, o la de defecto
periodningunoCódigo Box_Period: 1M, 1Y, 3Y
config[]JSON que acaba en client_order.config
group_idObligatorio para addons
activatefalseActiva el pedido en la misma llamada
invoice_optionissue-invoice o no-invoice
skip_validationfalseSalta la validación específica del tipo de servicio
titletítulo del producto
notesNotas internas del pedido
created_at / updated_atahoraPara migraciones desde otro billing
metaArray clave/valor que se escribe en client_order_meta
mark_invoice_paidfalseVer 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:

ValorEtiqueta en el panelConsecuencia
issue-invoiceAutomatically Issue Renewal InvoicesEl cron genera la factura de renovación antes de que expire
no-invoiceIssue Invoices ManuallyNunca 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étodoEstados de origen permitidosMensaje de error
activateOrder()pending_setup, failed_setupOnly pending setup or failed orders can be activated
suspendFromOrder()activeOnly active orders can be suspended
unsuspendFromOrder()suspendedOnly suspended orders can be unsuspended
uncancelFromOrder()canceledOnly canceled orders can be uncanceled
cancelFromOrder()todos menos canceled, pending_setup y failed_setupCannot cancel {status} order
Borrado desde área de clientepending_setup, failed_setupOnly 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:

EndpointUso
admin/order/status_history_get_listLee el historial de un pedido
admin/order/status_history_addAñade una entrada a mano
admin/order/status_history_deleteBorra 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).

ValorCálculo de la nueva fecha
from_expiration_dateDesde expires_at actual, o desde ahora si es null. Es el default
from_todayDesde ahora, siempre
from_greaterDesde 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, y suspended_at, unsuspended_at y canceled_at se ponen a null, con la nota Order renewed en 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_id no 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 en failed_setup mientras el maestro está active, y nadie te avisa.
  • Al renovar el maestro, si estaba en pending_setup, se renuevan también los addons.
  • order/delete acepta delete_addons (bool, default false). 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_addons de /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):

ClaveEtiqueta en el panelDefault
order_renewal_logicRenewal Logicfrom_expiration_date
show_addonsOrder Visibilityapagado
batch_suspend_reasonAuto Suspend Reasonvacío
batch_cancel_suspendedAuto Cancellation Policy1
batch_cancel_suspended_after_daysCancel After Suspension7
batch_cancel_suspended_reasonRequired Suspension Reasonvacío
suspend_reason_listSuspension Reasonsvací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:

  1. Se guarda META_CANCEL_AT_PERIOD_END en client_order_meta.
  2. Se añade al historial la nota Cancellation scheduled at the end of the current billing period.
  3. El pedido sigue active y el servicio sigue funcionando.
  4. 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.

CancelarBorrar
Qué hacestatus = canceled, canceled_at = now, expires_at = null, suspended_at = nullElimina la fila de client_order
Estados de origenTodos menos canceled, pending_setup, failed_setupCualquiera desde el panel
Desde el área de clienteVía ticket con rel_task = cancelSolo pending_setup y failed_setup
ReversibleSí, con uncancelFromOrder()No
HistorialSe conservaDesaparece

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:

  1. createOrder() dentro de la transacción → pedido en pending_setup.
  2. generateForOrder() → proforma con la serie y el due_at congelados.
  3. approveInvoice(use_credits = true) → aplica saldo si lo hay.
  4. markAsPaidByAdmin() → la factura pasa a paid y se reasigna la serie de pagadas.
  5. activateOrder() → aprovisionamiento y status = 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íntomaCausa probableCómo diagnosticar o arreglar
El pedido se queda en pending_setup tras pagarproduct.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 pagadaEl módulo de servicio lanzó excepción al aprovisionarEl mensaje exacto está en client_order_status; corrige la causa y usa order/activate
Only pending setup or failed orders can be activatedEl pedido ya está activeUsa force = true solo si quieres reaprovisionar de verdad
activateOrder devuelve OK pero no pasa nadaYa estaba active y no se pasó force: devuelve true sin actuarComprueba activated_at antes y después
Cannot cancel pending_setup orderEse estado no admite cancelaciónBórralo en lugar de cancelarlo
No se generan facturas de renovacióninvoice_option = 'no-invoice', o falta period, o falta expires_at, o ya hay una impagadaSELECT status, invoice_option, period, expires_at, unpaid_invoice_id FROM client_order WHERE id = X
La factura vencida no suspende el servicioLo que dispara la suspensión es client_order.expires_at, no invoice.due_atRevisa expires_at del pedido, no la factura
Un pedido nunca se suspendeexpires_at IS NULL o el estado no es activeLos pedidos sin fecha de expiración quedan fuera del SQL del batch
Se cancelaron pedidos que no debíanbatch_cancel_suspended = 1 y batch_cancel_suspended_after_days = 7 por defectoAjusta el plazo o apaga la política en /admin/order
Un pedido en failed_renew no aparece en el filtroget_status_pairs omite ese estado en 0.8.5Búscalo por SQL o pasando status a order/get_list
Los contadores del dashboard no cuadran con la base de datosOrder\Service::counter() filtra group_master = 1Los addons no se cuentan; compara con group_master = 1 en tu consulta
Un addon quedó sin activar y el maestro está activoactivateOrderAddons() registra los fallos individuales pero no abortaFiltra por group_id y revisa el estado de cada línea
Al borrar el pedido maestro quedaron addons sueltosdelete_addons es false por defectoPasa delete_addons: true en order/delete
El cliente no recibe el correo de activaciónFallo de envío logueado en el canal email, o plantilla inexistente para ese tipoVerifica que existe mod_service{tipo}_activated y que queue_once es mayor que cero
No hay aviso al cancelar un dominioservicedomain no trae plantilla _canceledCréala como plantilla custom
Currency rate for 'XXX' is not configuredLa moneda del pedido no tiene tasacurrency/update_rates o edita la moneda
Product :id is out of stock. (831)stock_control activo y sin unidadesEl stock se descuenta al activar, no al pedir
Group ID parameter is missing for addon product order (832)Addon sin pedido maestroPasa el group_id del maestro
No active gateway subscription that supports cancellation at period end...La pasarela no implementa la cancelación diferidaCancela de forma inmediata o migra el pedido a una suscripción compatible
HTTP 429 al probar cupones o crear pedidos en pruebascart_promo_apply_ip 10/hora, order_generation_ip 15/horaAñade tu IP a rate_limiter.whitelist_ips en config.php
El hook de pedido no se ejecuta nuncaLos listeners se registran en hook_batch_connect, primera tarea del cronCorre 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.