Clientes: grupos, campos personalizados, balance y el área de cliente

Por: Artiko
fossbillingclientesgruposcampos-personalizadosbalancecreditosarea-de-clienteregistroantispampermisosrate-limiting

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:

ColumnaTipo / valoresPara qué sirve
aidstringID externo. Pensado para migraciones: guarda ahí el identificador del sistema del que vienes
typecompany / individualDetermina si se muestran los campos de empresa en el perfil
companystringRazón social
company_vatstringNúmero de IVA/VAT del cliente
company_numberstringNúmero de registro mercantil
currencycódigo ISO 3 letrasMoneda del cliente. Se congela en el primer pedido
langlocaleIdioma preferido, usado en emails y en el área de cliente
timezonezona IANAAñadido en 0.8.4. Formatea fechas por usuario
tax_exemptboolSi es 1, ese cliente nunca paga impuesto
billing_emailemailAñadido en 0.8.5. Email de facturación separado del de login
notestextoNotas internas: solo visibles para el staff, nunca para el cliente
client_group_idFK a client_groupGrupo, por defecto 1
custom_1custom_20stringCampos personalizados
api_tokenstringToken de la API de cliente
email_approvedboolMarca 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:

  1. Segmentar cupones. La columna promo.client_groups es un JSON con los IDs de grupo a los que aplica el cupón. Un cliente fuera de la lista recibe Promo code cannot be applied to your account en el checkout.
  2. 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_1custom_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:

ClaveEtiqueta UIEfecto
disable_signupDisable New SignupsCierra el registro público. El endpoint guest client/create deja de admitir altas
require_email_confirmationRequire Email ConfirmationExige confirmar el email. Afecta también a los clientes existentes no confirmados, no solo a los nuevos
auto_login_after_signupAuto Login After SignupAñadido en 0.8.1. Deja al cliente con sesión iniciada nada más registrarse
disable_change_emailDisable Email ChangeEl 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 error Email required
  • first_name → mensaje de error First 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 plantillaCuándo se envía
mod_client_signupAl cliente, tras registrarse
mod_client_signup_adminAl staff, avisando del alta
mod_client_confirmEnlace de confirmación de email
mod_client_password_reset_requestPetición de restablecimiento
mod_client_password_reset_informationConfirmació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 con admin.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íticaTipoLímiteIntervaloQué protege
client_signupfixed_window51 hourAltas de cliente
api_loginfixed_window101 hourIntentos de login de cliente y de staff
client_password_reset_ipfixed_window101 hourPeticiones de reset por IP
client_password_reset_emailfixed_window31 hourPeticiones de reset por dirección
client_password_reset_confirm_ipfixed_window2060 secondsApertura del enlace de reset
client_password_reset_confirm_post_ipfixed_window2060 secondsEnvío del nuevo password
client_email_confirm_ipfixed_window2060 secondsApertura del enlace de confirmación
client_email_verification_resend_ipfixed_window301 hourReenvío de verificación por IP
client_email_verification_resend_accountfixed_window31 hourReenvío de verificación por cuenta
client_email_resend_ipfixed_window301 hourReenvío de email por IP
client_email_resend_accountfixed_window51 hourReenvío de email por cuenta
profile_password_change_ipfixed_window301 hourCambio de contraseña por IP
profile_password_change_accountfixed_window51 hourCambio 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:

ColumnaContenido
client_idEl cliente
typeTipo de movimiento, por defecto 'gift'
rel_idReferencia opcional a otro objeto, por ejemplo una factura
descriptionTexto libre que ve el cliente
amountImporte, positivo o negativo
currencyMoneda 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:

EndpointParámetros
client/balance_add_fundsRequeridos: id, amount, description. Opcionales: type, rel_id
client/balance_get_listPaginación estándar
client/balance_deleteid 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 invalid
  • Funds 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ámetroSemilla de instalaciónSignificado
funds_min_amount10Recarga mínima. Vacío = sin mínimo
funds_max_amount200Recarga 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:

ClaveNombre visibleEndpoints que cubre
viewView Client Detailsclient/get_list, client/get, client/get_pairs, client/get_statuses
createCreate Clientsclient/create
edit_profileEdit Client Profilesclient/update
impersonate_loginLogin as Clientclient/loginverificado en código
manage_api_keysManage Client API KeysGestión de client.api_token
change_passwordChange Client Passwordsclient/change_password
manage_balanceManage Client Balanceclient/balance_add_funds, balance_get_list, balance_deleteverificado en código
view_login_historyView Client Login Historyclient/login_history_get_list
manage_groupsManage Client Groupsclient/group_*verificado en código
deleteDelete Clientsclient/delete
bulk_deleteBulk Delete Clientsclient/batch_delete
exportExport Clientsclient/export_csv
manage_settingsManage Client SettingsPá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:

ClaveValor por defectoEfecto
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 (permiso client:export): vuelca los clientes a CSV.
  • client/delete y client/batch_delete (permisos client:delete y client:bulk_delete): borran al cliente y sus datos asociados.

El borrado en cascada lo ejecutan cuatro servicios coordinados, cada uno limpiando lo suyo:

MétodoQué 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:

ClaveFiltra sobre
client_statusclient.status
client_groupsclient.client_group_id
has_orderclient_order.product_id
has_order_with_statusclient_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íntomaCausa probableDiagnósticoSolución
Un cliente es expulsado en medio de la navegaciónAlguien cambió su status a suspended o canceledSELECT status FROM client WHERE id = ?Box_Authorization::isClientLoggedIn() destruye la sesión; reactivar el cliente
Authentication Failed código 204 con un token correctoEl cliente no está activeRevisar client.statusEl login por token filtra por status = 'active'
Authentication Failed código 203Se usó el email como usuario BasicRevisar el -u del curlEl 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 importadosSELECT 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 pagarEl saldo no cubre el total completo de la facturaComparar SUM(amount) con el total de la facturaEs 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 saldoLas líneas deposit están prohibidas para créditosVer Model_InvoiceItem::TYPE_DEPOSITEs intencional; el adaptador ClientBalance devuelve código 303
client/balance_delete borró el movimiento equivocadoSe pasó el client_id en vez del id de la líneaSELECT * 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 antiguasEl cron no está corriendoSELECT 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 concretopromo.client_groups no incluye su grupoMensaje Promo code cannot be applied to your accountAñadir el grupo al cupón o cambiar el grupo del cliente
Los grupos no aplican descuentoNo existe esa funcionalidad en el coreUsar cupones con recurring = true limitados por client_groups
Todos los registros fallan con 429El limitador ve una sola IP porque falta trusted_proxiestail del canal de seguridad en data/logConfigurar security.trusted_proxies antes de subir límites
Se cerró el registro pero siguen entrando altasdisable_signup solo cierra la vía públicaRevisar el log de actividadRestringir también el permiso client:create del staff
Clientes antiguos bloqueados tras activar la confirmaciónrequire_email_confirmation afecta a los existentes no confirmadosSELECT COUNT(*) FROM client WHERE email_approved = 0Marcar los históricos como aprobados antes de activar
Un campo personalizado obligatorio está vacío en clientes viejosLa obligatoriedad no es retroactivaConsultar custom_N en la tabla clientPedir el dato por campaña o por script
El email de confirmación nunca salequeue_once = 0 en la config mod_emailSELECT COUNT(*) FROM mod_email_queuePoner queue_once mayor que 0
Una plantilla de email de cliente no se envía y sale marcada como rotaUsa algo prohibido por el sandbox EmailPolicy, típicamente {% set %}Badge de error en /admin/email/templatesReescribir sin {% set %} o resetear con template_reset
No hay forma de volver al panel tras impersonarNo existe ese mecanismoCerrar sesión de cliente y entrar de nuevo en /admin
per_page grande devuelve errorSupera MAX_PER_PAGE = 500Mensaje The number of items per page (per_page) is too large...Paginar o usar client/export_csv
Un hook de cliente nunca se disparaLos listeners se descubren al ejecutar el cronConsultar extension_meta con meta_key = 'listener'php src/cron.php una vez
Massmailer rechaza el filtroSe usó una clave fuera de las cuatro admitidasError en handleInvalidFilterSolo client_status, client_groups, has_order, has_order_with_status
La API devuelve HTTP 200 pero nada funcionaLos errores de negocio viajan con status 200Inspeccionar el campo error de la respuestaComprobar 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.