Clientes: grupos, campos personalizados, balance y el área de cliente
Clientes: grupos, campos personalizados, balance y el área de cliente
En el capítulo 5 dejaste el catálogo listo para vender: productos con tipo, precios recurrentes, addons y cupones. Falta la otra mitad de cualquier venta, que es a quién le vendes. Y aquí es donde FOSSBilling sorprende a mucha gente, porque el modelo de cliente es más sencillo de lo que promete la interfaz y, a la vez, tiene tres o cuatro comportamientos que si no conoces te van a costar tiempo: un estado que revoca la sesión en caliente, unos grupos que no hacen lo que su nombre sugiere, un balance que no es un saldo sino un libro de movimientos, y una impersonación que te deja fuera del panel.
Este capítulo recorre el modelo Client tal y como está en el código de FOSSBilling 0.8.5: qué columnas existen y para qué se usan realmente, cómo se declaran los 20 campos personalizados, qué controles hay sobre el registro público, cómo funciona el crédito de cuenta y cuándo se aplica solo, qué protege cada uno de los 13 permisos del módulo, y hasta dónde llega el área de cliente que trae el core.
Al terminar tendrás el alta de clientes cerrada o abierta según te convenga, con antispam y límites de tasa encima, campos personalizados capturando lo que necesitas para facturar, saldo operativo para prepago y una idea exacta de qué puedes automatizar por API y qué no existe en el core por mucho que lo digan los blogs.
El modelo Client: estados, géneros y qué implica no estar active
El modelo vive en src/library/Model/Client.php y mapea la tabla client. Es todavía un modelo RedBeanPHP, no una entidad Doctrine, así que no esperes atributos de mapeo: las columnas son las de src/install/sql/structure.sql.
Las constantes de estado son tres y solo tres:
final public const string ACTIVE = 'active';
final public const string SUSPENDED = 'suspended';
final public const string CANCELED = 'canceled';
Y las de género, cuatro:
final public const string GENDER_MALE = 'male';
final public const string GENDER_FEMALE = 'female';
final public const string GENDER_NON_BINARY = 'nonbinary';
final public const string GENDER_OTHER = 'other';
Fíjate en que GENDER_NON_BINARY vale 'nonbinary', sin guion ni guion bajo. Si vas a importar clientes desde otro sistema y normalizas el género, ese es el literal exacto que acepta la columna.
El detalle que hay que grabarse: un cliente con status != 'active' no puede iniciar sesión ni autenticarse por API token. No es una comprobación en el formulario de login, es más profunda:
Box_Authorization::isClientLoggedIn()borra la sesión si el cliente asociado ya no está activo. Es decir, suspender a un cliente que está navegando lo expulsa en la siguiente petición, no en el siguiente login.- El login por token filtra por estado en la propia consulta. En
_tryTokenLogin():
case 'client':
$model = $this->di['db']->findOne('Client', 'api_token = ? AND status = ?', [$password, \Model_Client::ACTIVE]);
Un token válido de un cliente suspendido devuelve Authentication Failed con código 204.
stateDiagram-v2
[*] --> active: alta por client/create o registro publico
active --> suspended: admin cambia el estado
suspended --> active: admin reactiva
active --> canceled: admin cancela la cuenta
suspended --> canceled: admin cancela la cuenta
canceled --> active: admin reactiva
active --> [*]: client/delete con borrado en cascada
note right of active
Login web: si
API token: si
Compra en el carrito: si
end note
note right of suspended
Login web: no
API token: no codigo 204
Sesion abierta: se destruye
end note
Consulta los estados disponibles desde la API con client/get_statuses, que además devuelve el contador por estado y es lo que alimenta los filtros del listado del panel.
Advertencia: suspender un cliente no suspende sus pedidos ni para sus renovaciones. El ciclo de vida del servicio es independiente y se gestiona con client_order.status, que verás en el capítulo 7. Suspender al cliente solo le corta el acceso; las facturas se siguen generando.
Campos de negocio del cliente: currency, lang, timezone, tax_exempt y billing_email
Más allá del nombre y el email, la tabla client tiene un puñado de columnas que cambian el comportamiento de la facturación. Estas son las que importan:
| Columna | Tipo / valores | Para qué sirve |
|---|---|---|
aid | string | ID externo. Pensado para migraciones: guarda ahí el identificador del sistema del que vienes |
type | company / individual | Determina si se muestran los campos de empresa en el perfil |
company | string | Razón social |
company_vat | string | Número de IVA/VAT del cliente |
company_number | string | Número de registro mercantil |
currency | código ISO 3 letras | Moneda del cliente. Se congela en el primer pedido |
lang | locale | Idioma preferido, usado en emails y en el área de cliente |
timezone | zona IANA | Añadido en 0.8.4. Formatea fechas por usuario |
tax_exempt | bool | Si es 1, ese cliente nunca paga impuesto |
billing_email | Añadido en 0.8.5. Email de facturación separado del de login | |
notes | texto | Notas internas: solo visibles para el staff, nunca para el cliente |
client_group_id | FK a client_group | Grupo, por defecto 1 |
custom_1 … custom_20 | string | Campos personalizados |
api_token | string | Token de la API de cliente |
email_approved | bool | Marca de email confirmado |
Tres de esas columnas merecen comentario propio.
Uno. currency no es cosmético. Client\Service::addFunds() lanza You must define the client's currency before adding funds. si está vacía, y Order\Service::createOrder() cae en la moneda del cliente cuando no se pasa currency explícita. Si además la moneda no tiene tasa de conversión configurada, la creación del pedido falla con Currency rate for 'XXX' is not configured. Esa parte la cubriste en el capítulo 4.
Dos. tax_exempt cortocircuita todo el cálculo de impuestos. Client\Service::isClientTaxable() devuelve false y la factura sale con taxrate = 0, sin mirar reglas de país ni de estado. Es el interruptor por cliente para intracomunitario o para clientes exentos por normativa.
Tres. billing_email es de 0.8.5. Antes de esa versión todo iba al email de login. Si estás leyendo documentación o hilos anteriores a julio de 2026, ese campo no existía. Compruébalo en tu instalación con:
SHOW COLUMNS FROM client LIKE 'billing_email';
Si no devuelve fila, estás por debajo de 0.8.5 y toca actualizar antes de contar con esa separación.
Grupos de clientes: qué son y, sobre todo, qué no son
La tabla client_group tiene la estructura más escueta que vas a ver en todo el esquema: id, title, created_at, updated_at. Nada más. La instalación siembra una única fila, (1, 'Default').
La API está en el propio módulo Client y toda ella exige el permiso client:manage_groups:
client/group_create client/group_update client/group_get
client/group_get_pairs client/group_delete
La ruta del panel es /admin/client/group/:id.
Aquí viene el gotcha que cuesta dinero: los grupos NO llevan precios ni descuentos propios. No hay tabla de precios por grupo, no hay porcentaje de descuento por grupo, no hay nada. Si vienes de WHMCS y esperabas “client groups con override de precio”, no existe en el core de FOSSBilling 0.8.5.
En el core los grupos sirven exactamente para dos cosas:
- Segmentar cupones. La columna
promo.client_groupses un JSON con los IDs de grupo a los que aplica el cupón. Un cliente fuera de la lista recibePromo code cannot be applied to your accounten el checkout. - Segmentar campañas de Massmailer, con la clave de filtro
client_groups. Lo verás al final del capítulo.
Con eso en mente, la estrategia sensata es usar los grupos como etiqueta de segmento comercial (revendedores, mayoristas, cuentas heredadas) y montar el precio diferenciado con la herramienta que sí lo soporta: cupones recurrentes con recurring = true limitados a esos grupos, o precios editados a mano en el pedido, que el core respeta para todo lo que no sean dominios.
Los 20 campos personalizados y cómo se declaran
Desde 0.8.4 hay hasta 20 campos personalizados por cliente: custom_1 … custom_20. Antes eran menos, así que si tu instalación no te deja pasar de los primeros, revisa la versión.
Se configuran en /admin/extension/settings/client, pestaña Custom Fields. La plantilla es src/modules/Client/templates/admin/mod_client_settings.html.twig y el formulario es literalmente un bucle {% for i in 1..20 %} que emite tres campos por cada índice:
custom_fields[custom_{i}][title] texto: la etiqueta visible
custom_fields[custom_{i}][active] checkbox, valor 1
custom_fields[custom_{i}][required] checkbox, valor 1
No se guardan como parámetros del sistema sino como configuración del módulo, en extension_meta con ext = mod_client. Eso significa que se leen con $di['mod_config']('client') desde PHP y con admin.extension_config_get({ ext: 'mod_client' }) desde Twig, y que están cifradas en base de datos con el info.salt de tu config.php, como toda la config de módulo (ver el capítulo 3 sobre por qué perder el salt es irreversible).
El formulario público de registro conoce esa definición a través del endpoint guest client/custom_fields, que devuelve los campos activos con su título y su marca de obligatorio. Si escribes tu propia página de alta contra la API, ese es el endpoint que tienes que consultar antes de pintar el formulario.
Gotcha: marcar un campo como required afecta al alta y a la actualización de perfil, pero no rellena retroactivamente los clientes existentes. Vas a tener clientes antiguos con el campo vacío que no dan error hasta que alguien edite su perfil. Si necesitas el dato para facturar, hazte a la idea de una campaña de Massmailer pidiéndolo.
Controles de registro: cerrar altas, confirmar email y auto-login
En la misma página /admin/extension/settings/client, pestaña General, está el bloque Signup Controls. Son cuatro claves de la config mod_client:
| Clave | Etiqueta UI | Efecto |
|---|---|---|
disable_signup | Disable New Signups | Cierra el registro público. El endpoint guest client/create deja de admitir altas |
require_email_confirmation | Require Email Confirmation | Exige confirmar el email. Afecta también a los clientes existentes no confirmados, no solo a los nuevos |
auto_login_after_signup | Auto Login After Signup | Añadido en 0.8.1. Deja al cliente con sesión iniciada nada más registrarse |
disable_change_email | Disable Email Change | El cliente no puede cambiar su email desde el perfil |
La combinación que más gente configura mal es require_email_confirmation activado después de tener clientes. En el momento en que lo enciendes, todos los clientes con email_approved = 0 empiezan a ver el muro de confirmación. Si vas a activarlo en una instalación con histórico, mira primero cuántos afectas:
SELECT status, email_approved, COUNT(*)
FROM client
GROUP BY status, email_approved;
Y si la decisión es dar por buenos los antiguos, márcalos antes de encender el interruptor.
disable_signup es la palanca correcta cuando vendes solo a cuentas creadas por el staff. No la confundas con require_login del tema huraga, que oculta el sitio público pero no impide el alta por API.
Campos de perfil obligatorios y los dos que siempre lo son
Justo debajo está la sección Required Profile Fields: un multi-select con nombre required[] cuyas opciones exactas son estas once, agrupadas como en el panel:
- Grupo General:
last_name,company,gender,birthday - Grupo Address:
country,city,state,address_1,address_2,postcode,phone
Los que marques ahí se validan en el alta y en la actualización de perfil. El formulario público los consulta con el endpoint guest client/required, que devuelve la lista tal cual.
Dos campos no aparecen en esa lista porque son obligatorios siempre, validados con el atributo #[RequiredParams] sobre client/create:
email→ mensaje de errorEmail requiredfirst_name→ mensaje de errorFirst name is required
No hay forma de hacerlos opcionales desde el panel. Si estás automatizando altas por API y ves cualquiera de esos dos literales en el campo error.message de la respuesta, sabes exactamente qué falta.
Recuerda cómo responde la API: la mayoría de errores de negocio llegan con HTTP 200 y el campo error distinto de null. Comprobar el status code no sirve. Esto se detalla en el capítulo 14, pero conviene saberlo ya si vas a scriptear el alta masiva:
resp=$(curl -s -u "admin:$KEY" -X POST "https://tu-dominio/api/admin/client/create" \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","first_name":"Ana","last_name":"Perez","password":"Sup3rS3cret!"}')
echo "$resp" | jq -e '.error == null' >/dev/null \
|| echo "ERROR $(echo "$resp" | jq -r '.error.code'): $(echo "$resp" | jq -r '.error.message')"
Verificación por email: el KYC que no es un KYC
Con require_email_confirmation activo, el flujo es este: FOSSBilling envía la plantilla de email mod_client_confirm con un enlace, y cuando el cliente lo abre se marca client.email_approved = 1.
Eso es todo. No hay verificación documental ni KYC real en el core. No hay subida de documento de identidad, no hay comprobación de VAT contra VIES, no hay scoring de riesgo. Si tu negocio necesita KYC, es una extensión o un proceso externo.
Las plantillas de email del ciclo de vida del cliente son cinco, todas en src/modules/Client/templates/email/:
| Código de plantilla | Cuándo se envía |
|---|---|
mod_client_signup | Al cliente, tras registrarse |
mod_client_signup_admin | Al staff, avisando del alta |
mod_client_confirm | Enlace de confirmación de email |
mod_client_password_reset_request | Petición de restablecimiento |
mod_client_password_reset_information | Confirmación de que la contraseña cambió |
Se editan en /admin/email/templates. Un aviso que ahorra depuraciones: las plantillas de email se renderizan en un entorno Twig en sandbox (FOSSBilling\Twig\EmailPolicy) que prohíbe {% set %} y las llamadas a la API. Una plantilla que use algo prohibido falla al renderizar y bloquea el envío de ese email. Desde 0.8.2 el panel valida la sintaxis al guardar y marca las rotas. El detalle completo del motor de plantillas está en el capítulo 9.
El otro clásico: si no sale ningún correo, revisa queue_once en la config mod_email. Su valor por defecto es 0 y Email\Service::batchSend() lo lee literalmente como “cuántos emails envío por ejecución de cron”.
Antispam: Turnstile, hCaptcha y reCAPTCHA v3 sobre los formularios públicos
El módulo Antispam apareció en 0.8.0 sustituyendo al viejo Spamchecker, y añadió Cloudflare Turnstile y hCaptcha sobre los formularios públicos. En 0.8.1 se sumó reCAPTCHA v3, que puntúa en vez de plantear un reto.
La documentación oficial de seguridad describe el alcance del módulo como “CAPTCHA, IP blocking, disposable email detection, Stop Forum Spam lookups, and honeypot fields”.
Piezas concretas verificables en el código:
- Endpoint guest
antispam/recaptcha, que es el que consulta el frontend para el reto. - Función Twig
antispam_honeypot(), que devuelve{enabled, field}con el nombre del campo trampa que hay que pintar oculto en el formulario. Si escribes tu propio tema, tienes que incluirlo tú. - Página de ajustes propia: el módulo trae
templates/admin/mod_antispam_settings.html.twig, así que aparece con botón de configuración en Extensions y se lee conadmin.extension_config_get({ ext: 'mod_antispam' }).
Advertencia sobre el orden de defensas: el antispam se ejecuta en el formulario, pero el límite de tasa se aplica antes en la capa de API. Si estás detrás de Cloudflare o de cualquier proxy y no has configurado security.trusted_proxies, todos tus visitantes comparten la IP del proxy y el limitador los agrupa: el registro te va a fallar en masa con 429 y el antispam no tendrá nada que ver. Eso se arregla en config.php, no aquí.
Rate limiting de las rutas de cliente: registro, reset y reenvíos
FOSSBilling limita peticiones con symfony/rate-limiter y una caché de sistema de ficheros en PATH_CACHE bajo el namespace rate_limit. Viene activado por defecto. Estas son las políticas por defecto que afectan directamente al ciclo de vida del cliente:
| Política | Tipo | Límite | Intervalo | Qué protege |
|---|---|---|---|---|
client_signup | fixed_window | 5 | 1 hour | Altas de cliente |
api_login | fixed_window | 10 | 1 hour | Intentos de login de cliente y de staff |
client_password_reset_ip | fixed_window | 10 | 1 hour | Peticiones de reset por IP |
client_password_reset_email | fixed_window | 3 | 1 hour | Peticiones de reset por dirección |
client_password_reset_confirm_ip | fixed_window | 20 | 60 seconds | Apertura del enlace de reset |
client_password_reset_confirm_post_ip | fixed_window | 20 | 60 seconds | Envío del nuevo password |
client_email_confirm_ip | fixed_window | 20 | 60 seconds | Apertura del enlace de confirmación |
client_email_verification_resend_ip | fixed_window | 30 | 1 hour | Reenvío de verificación por IP |
client_email_verification_resend_account | fixed_window | 3 | 1 hour | Reenvío de verificación por cuenta |
client_email_resend_ip | fixed_window | 30 | 1 hour | Reenvío de email por IP |
client_email_resend_account | fixed_window | 5 | 1 hour | Reenvío de email por cuenta |
profile_password_change_ip | fixed_window | 30 | 1 hour | Cambio de contraseña por IP |
profile_password_change_account | fixed_window | 5 | 1 hour | Cambio de contraseña por cuenta |
Cuando se supera el límite, la respuesta lleva código 429 y la cabecera Retry-After con los segundos que faltan.
Para ajustarlos se sobreescribe la política en config.php, respetando el nombre exacto:
'rate_limiter' => [
'enabled' => true,
'whitelist_ips' => ['203.0.113.10'],
'policies' => [
'client_signup' => ['policy' => 'fixed_window', 'limit' => 20, 'interval' => '1 hour'],
],
],
whitelist_ips es la salida correcta para una integración con IP fija que da de alta clientes por API. Subir el límite global para que quepa un script es cambiar una puerta por un pasillo.
El registro público, paso a paso
Con todo lo anterior sobre la mesa, este es el recorrido completo de un alta desde el formulario público:
flowchart TD
A["Formulario publico de registro"] --> B{"disable_signup activo"}
B -->|si| BX["Alta cerrada"]
B -->|no| C["Antispam: Turnstile / hCaptcha / reCAPTCHA v3 + honeypot"]
C --> D{"Reto superado"}
D -->|no| DX["Rechazo del formulario"]
D -->|si| E["Rate limiter client_signup 5 por hora"]
E --> F{"Dentro del limite"}
F -->|no| FX["HTTP 429 con cabecera Retry-After"]
F -->|si| G["POST api/guest/client/create"]
G --> H["Validacion: email y first_name obligatorios"]
H --> I["Validacion de required[] y de custom_fields required"]
I --> J["Evento onBeforeClientSignUp"]
J --> K["INSERT en client con status active"]
K --> L["Emails mod_client_signup y mod_client_signup_admin"]
L --> M{"require_email_confirmation"}
M -->|si| N["Email mod_client_confirm y espera de email_approved"]
M -->|no| O["Cuenta usable de inmediato"]
N --> O
O --> P{"auto_login_after_signup"}
P -->|si| Q["Sesion de cliente creada"]
P -->|no| R["Redirige a la pagina de login"]
Q --> S["Evento onAfterClientSignUp"]
R --> S
Los eventos onBeforeClientSignUp y onAfterClientSignUp son puntos de enganche reales: un hook onBefore* puede abortar el alta lanzando una excepción, lo que es la forma limpia de bloquear dominios de correo desechables sin tocar el core.
Balance de cuenta: una tabla de movimientos, no un saldo
Esta es la parte que más gente entiende al revés. No hay una columna de saldo en la tabla client. Lo que hay es la tabla client_balance, donde cada movimiento es una fila:
| Columna | Contenido |
|---|---|
client_id | El cliente |
type | Tipo de movimiento, por defecto 'gift' |
rel_id | Referencia opcional a otro objeto, por ejemplo una factura |
description | Texto libre que ve el cliente |
amount | Importe, positivo o negativo |
currency | Moneda del movimiento |
El saldo es literalmente una suma. Client\Service::getClientBalance() ejecuta:
SELECT SUM(amount) FROM client_balance WHERE client_id = ? GROUP BY client_id
Las consecuencias prácticas de este diseño son tres:
Uno. El saldo nunca se “corrige”: se compensa con un movimiento nuevo. Si te equivocas al añadir fondos, o borras la línea concreta o añades una línea negativa.
Dos. El histórico es la auditoría. Cualquier discrepancia se investiga listando las filas, no mirando un número.
Tres. El endpoint client/balance_delete recibe el id de la LÍNEA, no el del cliente. Es el error de dedo más caro de esta API: pasar el client_id borra el movimiento cuyo id coincide, que puede ser de otra persona.
graph TD
G["Fila type=gift"] --> SUM["SUM de amount en client_balance"]
D["Fila type=deposit"] --> SUM
R["Fila type=refund"] --> SUM
SUM --> BAL["Saldo del cliente"]
BAL --> Q{"Saldo cubre el total completo de la factura"}
Q -->|no| N1["No se aplican creditos en el checkout"]
Q -->|si| Y1["approveInvoice con use_credits true"]
Y1 --> TP["tryPayWithCredits"]
TP --> EX{"La linea es de tipo deposit"}
EX -->|si| N2["Prohibido: no se recarga saldo con saldo"]
EX -->|no| Y2["Factura pagada con credito"]
Añadir fondos como staff y recargar saldo como cliente
Hay dos caminos hacia el mismo client_balance, y no se comportan igual.
Como staff, por API o por panel
Los tres endpoints van con el permiso client:manage_balance:
| Endpoint | Parámetros |
|---|---|
client/balance_add_funds | Requeridos: id, amount, description. Opcionales: type, rel_id |
client/balance_get_list | Paginación estándar |
client/balance_delete | id de la línea de movimiento |
Ejemplo de un abono manual:
curl -s -u "admin:$KEY" -X POST "https://tu-dominio/api/admin/client/balance_add_funds" \
-H 'Content-Type: application/json' \
-d '{"id":42,"amount":25.00,"description":"Compensacion por incidencia del 2026-08-01"}'
Client\Service::addFunds() tiene exactamente tres mensajes de error, y son autoexplicativos:
You must define the client's currency before adding funds.Funds amount is invalidFunds description is invalid
El primero es el que más aparece: clientes importados sin moneda asignada. Es un UPDATE client SET currency = 'EUR' WHERE ... de un minuto, pero hasta que lo haces no puedes abonarles nada.
Como cliente, recargando saldo
El cliente recarga desde el área de cliente. Internamente se genera una factura de depósito con Invoice\Service::generateFundsInvoice(), acotada por dos parámetros de sistema que configuras en la pantalla de ajustes de facturas del capítulo 8:
| Parámetro | Semilla de instalación | Significado |
|---|---|---|
funds_min_amount | 10 | Recarga mínima. Vacío = sin mínimo |
funds_max_amount | 200 | Recarga máxima. Vacío = sin máximo |
La disponibilidad de la recarga se consulta desde el frontend con el endpoint guest invoice/funds_enabled.
Cuándo se usan los créditos automáticamente y por qué a veces no
El crédito no se aplica siempre, y esto explica el 90 % de los tickets de “tengo saldo pero me pide pagar”.
El mecanismo es Invoice\Service::approveInvoice($invoice, ['use_credits' => true]), que llama internamente a tryPayWithCredits(). Quién pasa ese use_credits y con qué condición:
En el checkout del carrito. Cart\Service::createFromCart() solo pasa use_credits = true si el saldo cubre el total completo de la factura. La comprobación es literal: $balanceAmount >= $ca['total']. Con 40 de saldo y una factura de 50, no se aplica nada: ni 40 a cuenta ni pago parcial. O cubre todo o no cubre nada.
En el cron. La tarea invoice_batch_pay_with_credits es la primera que ejecuta Cron\Service::runCrons() después del evento de arranque. Recorre las facturas pendientes intentando saldarlas con crédito. Si un cliente recarga hoy y tiene una factura abierta de ayer, es esa pasada la que la liquida, no el momento del ingreso.
A mano. invoice/pay_with_credits para una factura concreta y invoice/batch_pay_with_credits para el lote.
La excepción dura: las líneas de tipo deposit (Model_InvoiceItem::TYPE_DEPOSIT) no se pueden pagar con créditos. El comentario en el modelo lo dice explícitamente y el motivo es evidente: evitar el bucle de recargar saldo usando saldo. El adaptador de pago ClientBalance implementa la misma regla por su cuenta y rechaza esas facturas con el código 303, además de exigir que la pasarela esté habilitada (código 301) y que el cliente autenticado sea el titular de la factura.
Con esas piezas, el patrón de prepago que funciona es: recarga del cliente por pasarela → factura de depósito pagada → fila deposit en client_balance → cron aplica el crédito a las facturas de servicio. Y la pasarela ClientBalance habilitada para que el cliente pueda liquidar a mano una factura concreta sin esperar al cron.
Impersonación: qué hace client/login por dentro y qué no deshace
La ruta del panel es /admin/client/login/:id y el endpoint es admin/client/login con { "id": 123 }. El permiso necesario es client:impersonate_login.
La implementación completa, en src/modules/Client/Api/Admin.php, es esta:
$session->set('client_id', $client->id);
$this->getDi()['logger']->info('Logged in as client #%s', $client->id);
Dos líneas. Eso significa, literalmente:
Uno. Escribe client_id directamente en la sesión del navegador que estás usando ahora mismo. No hay token temporal, no hay sesión paralela, no hay ventana de tiempo.
Dos. No hay botón de “volver al panel”. La sesión sigue teniendo tu identidad de admin, pero el área de cliente ahora te reconoce como ese cliente. Para salir, cierras sesión de cliente y vuelves a entrar en /admin.
Tres. Queda registrado en el log de actividad con el mensaje literal Logged in as client #%s, consultable en /admin/activity.
sequenceDiagram
autonumber
participant A as Administrador
participant P as Panel /admin/client/manage/:id
participant API as admin/client/login
participant S as Sesion del navegador
participant C as Area de cliente
A->>P: Abre la ficha del cliente
P->>API: POST con id del cliente
API->>API: Comprueba permiso client:impersonate_login
API->>S: session.set client_id = id del cliente
API->>API: logger.info Logged in as client
S-->>A: Redirige al area de cliente
A->>C: Navega como si fuera el cliente
Note over A,C: No existe boton de volver al panel
A->>C: Cerrar sesion manualmente
A->>P: Nuevo login en /admin
Advertencia operativa: como no hay separación de sesión, cualquier acción que hagas mientras impersonas queda registrada como del cliente en los flujos de área de cliente. Restringe impersonate_login al grupo de staff que realmente lo necesita para soporte, y no lo des por defecto.
Los 13 permisos del módulo Client y qué endpoint protege cada uno
Client\Service::getModulePermissions() declara exactamente trece claves. Estos son sus nombres visibles en la matriz de permisos de grupos de staff, junto con la superficie que gobiernan:
| Clave | Nombre visible | Endpoints que cubre |
|---|---|---|
view | View Client Details | client/get_list, client/get, client/get_pairs, client/get_statuses |
create | Create Clients | client/create |
edit_profile | Edit Client Profiles | client/update |
impersonate_login | Login as Client | client/login — verificado en código |
manage_api_keys | Manage Client API Keys | Gestión de client.api_token |
change_password | Change Client Passwords | client/change_password |
manage_balance | Manage Client Balance | client/balance_add_funds, balance_get_list, balance_delete — verificado en código |
view_login_history | View Client Login History | client/login_history_get_list |
manage_groups | Manage Client Groups | client/group_* — verificado en código |
delete | Delete Clients | client/delete |
bulk_delete | Bulk Delete Clients | client/batch_delete |
export | Export Clients | client/export_csv |
manage_settings | Manage Client Settings | Página /admin/extension/settings/client |
Los tres marcados como verificados son los que aparecen asociados explícitamente en el material de referencia. Los demás siguen la convención de nombre del módulo; si necesitas certeza sobre uno concreto en tu versión, la comprobación es directa sobre el código:
grep -n "checkPermissions('client'" src/modules/Client/Api/Admin.php
El inventario completo de endpoints admin del módulo Client, para que sepas qué hay que cubrir:
client/get_list client/get_pairs client/get client/create
client/update client/delete client/change_password
client/get_statuses client/login client/login_history_get_list
client/batch_delete client/export_csv client/batch_expire_password_reminders
client/group_create client/group_update client/group_get client/group_get_pairs
client/group_delete client/balance_add_funds client/balance_get_list
client/balance_delete
client/batch_expire_password_reminders es el que ejecuta el cron: borra los recordatorios de contraseña no confirmados a las 2 horas.
Y la superficie guest, que es la que expones a internet sin autenticación, son siete métodos:
client/create client/login client/reset_password client/update_password
client/required client/custom_fields client/is_email_validation_required
Todo lo que esté ahí es público por definición. Si escribes una extensión con Api/Guest.php, aplica el mismo criterio: cualquier método público de esa clase es alcanzable desde internet sin credenciales.
Las rutas del panel relacionadas con clientes son estas siete:
/admin/client
/admin/client/index
/admin/client/create
/admin/client/manage/:id
/admin/client/group/:id
/admin/client/login/:id
/admin/client/logins
El área de cliente: el tema huraga y los endpoints que la alimentan
El área de cliente corre sobre el tema huraga, en src/themes/huraga/. Su manifest.json lo describe como “Default client area theme” y está construido con Bootstrap 5 directo, sin Tabler. El panel de administración usa el otro tema incluido, admin_default.
La detección de área es por nombre de carpeta: Theme\Model\Theme::isAdminAreaTheme() devuelve str_contains($this->name, 'admin_'). Es decir, un tema de administración debe llevar admin_ en el nombre de su directorio.
Los ajustes del tema viven en config/settings_data.json (el repositorio trae settings_data.json.example, hay que copiarlo) y luego pasan a base de datos. Las claves que tocan directamente al ciclo de vida del cliente, con sus valores del preset Default:
| Clave | Valor por defecto | Efecto |
|---|---|---|
show_signup_link | "1" | Muestra el enlace de registro |
show_password_reset_link | "1" | Muestra el enlace de recuperación |
require_login | "0" | Si es "1", oculta el sitio público a visitantes |
sidebar_balance_enabled | "1" | Muestra el saldo en la barra lateral |
signup_tos | "0" | Exige aceptar los términos al registrarse |
checkout_tos | "0" | Exige aceptar los términos en el checkout |
showcase_enabled | "1" | Bloque de bienvenida en el dashboard |
Gotcha: los valores se guardan como cadenas, no como booleanos. En Twig hay que comparar con == '1', no con is true. Un {% if settings.footer_enabled %} evalúa a verdadero también con "0", porque "0" es una cadena no vacía en ese contexto.
Para extender el área de cliente sin tocar el core hay dos mecanismos:
Sobreescritura de plantillas. El cargador Twig busca en este orden: themes/<tema>/html_custom/, luego themes/<tema>/html/, y por último modules/*/templates/client/. Basta con crear un fichero con el mismo nombre en html_custom/ para ganar. Ese directorio no existe en los temas incluidos, lo tienes que crear tú.
Widgets. Desde 0.8.0 hay slots donde inyectar plantillas desde un módulo. Los relevantes para el área de cliente son:
client.theme.body.start client.theme.body.end
client.theme.header.start client.theme.header.end
client.theme.content.before client.theme.content.after
client.theme.footer.start client.theme.footer.end
client.page.login.form.before client.page.login.form.after
client.index.dashboard.client.before client.index.dashboard.client.after
client.index.dashboard.guest.before client.index.dashboard.guest.after
client.order.manage.content.start client.order.manage.details.after
client.order.manage.actions.end
client.order.manage.service.before client.order.manage.service.after
Todo el detalle de temas, widgets e i18n está en el capítulo 15.
Contraseñas, historial de accesos y token de API del cliente
Contraseñas
El cliente restablece su contraseña por la vía pública con los endpoints guest client/reset_password (pide el email y dispara mod_client_password_reset_request) y client/update_password (consume el enlace y envía mod_client_password_reset_information).
Novedad de 0.8.5: tras un restablecimiento de contraseña se invalidan las sesiones existentes. Antes, cambiar la contraseña no expulsaba a quien ya estuviera dentro.
El staff cambia contraseñas con client/change_password y el permiso client:change_password. Y el cron limpia los recordatorios pendientes con client_batch_expire_password_reminders, que caduca a las 2 horas los no confirmados.
Historial de accesos
Se consulta con client/login_history_get_list, permiso client:view_login_history, y su vista en el panel es /admin/client/logins. Los eventos onBeforeClientLogin, onAfterClientLogin y onEventClientLoginFailed permiten construir alertas propias sobre intentos fallidos.
Token de API del cliente
Cada cliente tiene su propio client.api_token. Se gestiona con dos endpoints del módulo Profile en rol cliente:
client/profile/api_key_get
client/profile/api_key_reset
Detalle de seguridad importante: esos métodos, junto con profile_generate_api_key, están en la lista de shouldPreferSessionAuth(). Exigen sesión de navegador y no aceptan autenticación por token. No puedes usar el token del cliente para leer o rotar el token del cliente, lo que evita que una fuga de token se perpetúe sola.
Con el token en la mano, la autenticación de la API de cliente es HTTP Basic con el usuario literal client:
curl -s -u "client:$CLIENT_KEY" https://tu-dominio/api/client/profile/get
curl -s -u "client:$CLIENT_KEY" "https://tu-dominio/api/client/invoice/get_list?per_page=10"
El usuario no es el email, es la cadena client. Si pones el email obtienes Authentication Failed con código 203.
Y sobre paginación: los listados usan PaginationOptions con DEFAULT_PER_PAGE = 100 y MAX_PER_PAGE = 500. Pedir más de 500 devuelve:
The number of items per page (per_page) is too large. Please specify a smaller number.
Así que para exportar 5.000 clientes por API hay que paginar en al menos diez llamadas, o usar client/export_csv.
Exportar, borrar en cascada y el estado real del GDPR en el core
Hablemos claro de esto porque hay mucha desinformación.
El módulo Cookieconsent que trae el core es mínimo. Su manifiesto lo describe como “Show cookie consent message to comply with European Cookie Law” y su Service.php tiene un único método de negocio, getMessage(). Muestra un banner. Nada más.
No hay derecho al olvido automatizado ni exportación de datos GDPR en el core más allá de estas dos herramientas:
client/export_csv(permisoclient:export): vuelca los clientes a CSV.client/deleteyclient/batch_delete(permisosclient:deleteyclient:bulk_delete): borran al cliente y sus datos asociados.
El borrado en cascada lo ejecutan cuatro servicios coordinados, cada uno limpiando lo suyo:
| Método | Qué borra |
|---|---|
Client\Service::rmByClient() | El cliente y sus datos propios, incluido el balance |
Order\Service::rmByClient() | Los pedidos del cliente |
Invoice\Service::rmByClient() | Las facturas del cliente |
Email\Service::rmByClient() | El histórico de correo del cliente |
Advertencia de negocio, no técnica: borrar un cliente borra sus facturas. En la mayoría de jurisdicciones tienes obligación de conservar la documentación fiscal durante años. El botón de borrar existe y funciona; que sea la respuesta correcta a una solicitud de supresión es otra discusión, y probablemente la respuesta sea anonimizar en vez de borrar. FOSSBilling no trae anonimización: eso es un script tuyo o una extensión.
Si vas a montar un proceso de supresión, los eventos disponibles te dan el punto de enganche:
onBeforeAdminClientDelete onAfterAdminClientDelete
Lista completa de eventos del ciclo de vida del cliente, útiles para automatizar sin parchear el core:
onBeforeAdminClientCreate onAfterAdminClientCreate
onBeforeAdminClientUpdate onAfterAdminClientUpdate
onBeforeAdminClientDelete onAfterAdminClientDelete
onBeforeClientSignUp onAfterClientSignUp
onBeforeClientLogin onAfterClientLogin
onEventClientLoginFailed
onBeforeClientProfileUpdate onAfterClientProfileUpdate
El gotcha número uno de los hooks: los listeners se descubren cuando corre el cron. hook_batch_connect es la primera tarea de Cron\Service::runCrons(). Si añades un hook nuevo y no pasa nada, ejecuta php src/cron.php una vez y comprueba que se registró:
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;
Segmentar clientes para campañas con Massmailer
El módulo Massmailer no es core (no está en Module::CORE_MODULES), así que hay que activarlo en Extensions. Sus rutas son /admin/massmailer y /admin/massmailer/message/:id.
Una campaña es una entidad MassmailerMessage con estados STATUS_DRAFT y STATUS_SENT, y campos from_email, from_name, subject, content, filter (JSON) y sent_at.
El filtro admite exactamente cuatro claves. Ni una más:
| Clave | Filtra sobre |
|---|---|
client_status | client.status |
client_groups | client.client_group_id |
has_order | client_order.product_id |
has_order_with_status | client_order.status |
Cualquier otra clave hace fallar la validación en handleInvalidFilter. No puedes segmentar por país, por saldo, por fecha de alta ni por campo personalizado. Si necesitas eso, la salida es consultar la base de datos, exportar y usar una herramienta de email marketing externa.
Endpoints de la API:
massmailer/get_list massmailer/get massmailer/create massmailer/update
massmailer/copy massmailer/delete massmailer/receivers massmailer/preview
massmailer/send massmailer/send_test massmailer/get_test_client
massmailer/receivers es el que hay que llamar antes de enviar: devuelve a quién va a llegar con el filtro actual. Y send_test usa el cliente configurado en test_client_id, una de las tres claves de config que el módulo siembra en su install() junto con limit e interval, que controlan el ritmo de envío para no reventar la cola de correo.
Aquí es donde los grupos de clientes por fin ganan su sueldo: client_groups es la única segmentación estructurada que tienes, así que si vas a hacer comunicaciones diferenciadas, define los grupos pensando en eso desde el principio.
Errores comunes y diagnóstico
| Síntoma | Causa probable | Diagnóstico | Solución |
|---|---|---|---|
| Un cliente es expulsado en medio de la navegación | Alguien cambió su status a suspended o canceled | SELECT status FROM client WHERE id = ? | Box_Authorization::isClientLoggedIn() destruye la sesión; reactivar el cliente |
Authentication Failed código 204 con un token correcto | El cliente no está active | Revisar client.status | El login por token filtra por status = 'active' |
Authentication Failed código 203 | Se usó el email como usuario Basic | Revisar el -u del curl | El usuario es literalmente client, no el email |
You must define the client's currency before adding funds. | client.currency vacío, típico en clientes importados | SELECT id, email FROM client WHERE currency IS NULL OR currency = '' | Asignar moneda al cliente antes de abonar |
| El cliente tiene saldo pero el checkout le pide pagar | El saldo no cubre el total completo de la factura | Comparar SUM(amount) con el total de la factura | Es el diseño de Cart\Service::createFromCart(); recargar hasta cubrir o pagar con la pasarela |
| La recarga de saldo no se paga con el propio saldo | Las líneas deposit están prohibidas para créditos | Ver Model_InvoiceItem::TYPE_DEPOSIT | Es intencional; el adaptador ClientBalance devuelve código 303 |
client/balance_delete borró el movimiento equivocado | Se pasó el client_id en vez del id de la línea | SELECT * FROM client_balance WHERE client_id = ? | El parámetro id es el de la línea |
| Los créditos no se aplican solos a facturas antiguas | El cron no está corriendo | SELECT value FROM setting WHERE param = 'last_cron_exec' | Programar php cron.php cada 5 minutos |
| No se puede aplicar un cupón a un cliente concreto | promo.client_groups no incluye su grupo | Mensaje Promo code cannot be applied to your account | Añadir el grupo al cupón o cambiar el grupo del cliente |
| Los grupos no aplican descuento | No existe esa funcionalidad en el core | — | Usar cupones con recurring = true limitados por client_groups |
| Todos los registros fallan con 429 | El limitador ve una sola IP porque falta trusted_proxies | tail del canal de seguridad en data/log | Configurar security.trusted_proxies antes de subir límites |
| Se cerró el registro pero siguen entrando altas | disable_signup solo cierra la vía pública | Revisar el log de actividad | Restringir también el permiso client:create del staff |
| Clientes antiguos bloqueados tras activar la confirmación | require_email_confirmation afecta a los existentes no confirmados | SELECT COUNT(*) FROM client WHERE email_approved = 0 | Marcar los históricos como aprobados antes de activar |
| Un campo personalizado obligatorio está vacío en clientes viejos | La obligatoriedad no es retroactiva | Consultar custom_N en la tabla client | Pedir el dato por campaña o por script |
| El email de confirmación nunca sale | queue_once = 0 en la config mod_email | SELECT COUNT(*) FROM mod_email_queue | Poner queue_once mayor que 0 |
| Una plantilla de email de cliente no se envía y sale marcada como rota | Usa algo prohibido por el sandbox EmailPolicy, típicamente {% set %} | Badge de error en /admin/email/templates | Reescribir sin {% set %} o resetear con template_reset |
| No hay forma de volver al panel tras impersonar | No existe ese mecanismo | — | Cerrar sesión de cliente y entrar de nuevo en /admin |
per_page grande devuelve error | Supera MAX_PER_PAGE = 500 | Mensaje The number of items per page (per_page) is too large... | Paginar o usar client/export_csv |
| Un hook de cliente nunca se dispara | Los listeners se descubren al ejecutar el cron | Consultar extension_meta con meta_key = 'listener' | php src/cron.php una vez |
| Massmailer rechaza el filtro | Se usó una clave fuera de las cuatro admitidas | Error en handleInvalidFilter | Solo client_status, client_groups, has_order, has_order_with_status |
| La API devuelve HTTP 200 pero nada funciona | Los errores de negocio viajan con status 200 | Inspeccionar el campo error de la respuesta | Comprobar siempre .error, nunca el status |
Lo que queda operativo
Con este capítulo tienes el modelo de cliente entero mapeado contra el código de 0.8.5: los tres estados y su efecto real sobre la sesión y sobre el token, las columnas de negocio que cambian la facturación (currency, tax_exempt, billing_email, timezone), los 20 campos personalizados declarados como config de módulo, y la verdad incómoda sobre los grupos, que sirven para segmentar cupones y campañas y para nada más.
Tienes también el registro público controlado en sus cuatro palancas, con antispam y con las trece políticas de límite de tasa que lo rodean; el balance entendido como libro de movimientos y no como saldo, con la regla del total completo y la prohibición de pagar depósitos con crédito; la impersonación con su comportamiento exacto y su riesgo; los trece permisos del módulo y la superficie guest que expones a internet; y los límites reales del área de cliente y del GDPR en el core.
Lo que falta es el eslabón que une clientes con catálogo: el pedido. En el capítulo 7 recorres el ciclo de vida completo del servicio, desde el carrito hasta la suspensión automática por impago, con las reglas exactas de transición entre estados, la lógica de renovación configurable y los batches de cron que lo mueven todo sin que nadie pulse un botón.