Pasarelas de pago: Stripe, PayPal, IPN y tu propio adaptador

Por: Artiko
fossbillingpagosstripepaypalipnwebhookstransaccionespayment-adapteridempotenciacriptomonedas

Pasarelas de pago: Stripe, PayPal, IPN y tu propio adaptador

En el capítulo 8 dejaste facturas emitidas, aprobadas y con recordatorios saliendo por correo. Falta la parte que convierte una factura en dinero: el cliente pulsa “Pagar”, desaparece hacia Stripe o PayPal, y unos segundos después alguien tiene que decirle a FOSSBilling que ese cobro ocurrió de verdad. Ese “alguien” es un callback HTTP contra ipn.php, y todo lo que puede salir mal en un sistema de facturación se concentra ahí.

El problema real no es enchufar Stripe. Es entender por qué un pago aparece cobrado en el panel del proveedor y la factura sigue en unpaid, por qué un webhook se acredita en la pasarela equivocada cuando tienes dos configuraciones sobre la misma cuenta Stripe, o por qué el IPN antiguo de BoxBilling devuelve 404 después de migrar.

Al final vas a tener Stripe operativo con firma de webhook verificada, PayPal Payments Standard con verificación de destinatario, la pasarela Custom para transferencias bancarias, un árbol de decisión para depurar un pago que no acredita y un Payment_Adapter propio con verificación servidor-a-servidor e idempotencia. La versión de referencia es FOSSBilling 0.8.5, publicada el 2026-07-20: los números de línea y las firmas corresponden a esa versión, y donde algo cambió entre 0.8.0 y 0.8.5 lo digo explícitamente.

Las cuatro pasarelas del núcleo y lo que la gente cree que existe pero no existe

Empieza por lo incómodo. El núcleo de FOSSBilling 0.8.5 trae cuatro adaptadores de pago. Ni uno más:

src/library/Payment/Adapter/ClientBalance.php    160 lineas
src/library/Payment/Adapter/Custom.php           134
src/library/Payment/Adapter/PayPalEmail.php      489
src/library/Payment/Adapter/Stripe.php          1614
AdaptadorClasePago únicoSuscripcionesEstado real
StripePayment_Adapter_StripeMantenido activamente. Es la pasarela de referencia del proyecto: Elements, PaymentIntents, SetupIntents y webhooks firmados.
PayPalPayment_Adapter_PayPalEmailFuncional, pero usa el PayPal Payments Standard clásico con cgi-bin/webscr e IPN _notify-validate. No es la REST API moderna.
ClientBalancePayment_Adapter_ClientBalanceNoFuncional. Paga la factura con el saldo del cliente. No es un procesador externo.
CustomPayment_Adapter_CustomPasarela offline: transferencia, efectivo, cheque. La confirmación la hace un administrador.

Que las 1614 líneas de Stripe.php contrasten con las 134 de Custom.php te dice dónde está el esfuerzo de mantenimiento del proyecto.

Y ahora lo que no existe, porque circula mucha información falsa:

  • Coinbase Commerce: no existe. Ninguna pasarela de criptomonedas vive en el núcleo.
  • Mollie: no está en el núcleo. Es extensión (FOSSBilling/Mollie), y ese repositorio fue archivado el 2026-02-16 en versión 0.0.5. Sigue publicada y auto-instalable desde el directorio, pero sin mantenedor.
  • Authorize.net: no aparece ni en el núcleo, ni en el directorio oficial, ni en extensions-extra. No hay módulo mantenido verificable.
  • 2Checkout / Verifone: vive en FOSSBilling/extensions-extra como gateways/TwoCheckout/TwoCheckout.php, marcado “Reportedly functional”.

Advertencia: si vas a montar una tienda alrededor de FOSSBilling y tu pasarela no es Stripe, PayPal o una cripto, la respuesta honesta es que probablemente tengas que escribir el adaptador tú. La última sección de este capítulo existe precisamente por eso.

La purga de pasarelas de 2023, el directorio de extensiones y las bajas de extensions-extra

Esto no siempre fue así. El PR #1535, “Remove payment gateways we can’t really maintain”, se fusionó el 14 de agosto de 2023. La justificación textual del contribuidor fue: “since we can’t (and don’t plan to) maintain these, it’s best to remove them from the main repo and no longer ship them with every installation”. Los adaptadores afectados se movieron a repositorios separados. En paralelo, el PR #1527 hizo lo mismo con los server managers Virtualmin e ISPConfig, tema del capítulo 11.

El directorio oficial (https://extensions.fossbilling.org/api/extensions) publica hoy 17 extensiones, de las cuales 12 son de tipo payment-gateway:

idNombreVersiónRepositorio origenDev aprobado
BitcartBitcart1.1.0bitcart/bitcart-fossbillingno
blockonomicsBlockonomics BTC + USDT ERC-200.2.0anktd/blockonomics-fossbilling
BTCPayBTCPay0.1.5ChristianGabs/btcpay-fossbillingno
CoinPayPortalCoinPayPortal Crypto Payments1.0.0profullstack/coinpayportalno
FaucetPayFaucetPay0.1.0neto737/FaucetPay-FOSSBilling
MollieMollie0.0.5FOSSBilling/Mollie (archivado)
PAYEERPAYEER0.1.1neto737/PAYEER-FOSSBilling
paygatePaygate Crypto Payments1.0.1hanihiyoze/paygate-fossbillingno
RazorpayRazorpay0.1.0albinvar/Razorpay-FOSSBillingno
UddoktaPayUddoktaPay1.0.1UddoktaPay/FOSSBillingno
XenditXendit1.0.0FZFR/Xendit-FOSSBillingno
YocoYoco0.5.1demassimo/Yoco_Payment_Gateway_Fossbillingno

Cuenta las criptomonedas: Bitcart, Blockonomics, BTCPay, CoinPayPortal, FaucetPay y paygate. Seis de doce. El ecosistema fiat que no sea Stripe ni PayPal es escaso, y esa es una restricción de diseño que debes asumir antes de comprometerte con la plataforma.

Queda el tercer nivel: FOSSBilling/extensions-extra, descrito como “extensions maintained by the community on a best-effort basis”. Su README lista pasarelas con fecha de eliminación:

GatewayEstado declaradoFecha de eliminación
gateways/AliPayUntested31-Aug-2026
gateways/InterkassaMay not work31-Aug-2026
gateways/OnebipUntested31-Aug-2026
gateways/MollieFunctionalN/A
gateways/TwoCheckoutReportedly functional31-Aug-2026
gateways/WebMoneyUntested31-Aug-2026

Si alguna de esas cinco es tu plan de negocio, clónala hoy. El README aclara que las fechas “may be extended/removed, if appropriate”, pero no cuentes con ello.

Cómo descubre FOSSBilling un adaptador: Finder, layouts admitidos y la tabla pay_gateway

FOSSBilling no tiene una lista de pasarelas escrita a mano en ningún sitio. Las descubre escaneando el disco. El código vive en src/modules/Invoice/ServicePayGateway.php, método getAvailable() (L104-144):

$finder = new Finder();
$finder->files()
    ->in(Path::join(PATH_LIBRARY, 'Payment', 'Adapter'))
    ->name('*.php')
    ->depth('== 0');            // archivos planos: Stripe.php

// y ademas subdirectorios de un nivel:
$subFinder->files()
    ->in(Path::join(PATH_LIBRARY, 'Payment', 'Adapter', '*'))
    ->name('*.php')
    ->depth('== 0');            // subcarpetas: Mollie/Mollie.php

De ahí salen dos layouts admitidos, y solo dos: src/library/Payment/Adapter/MiGateway.php (plano, para un adaptador de un archivo) y src/library/Payment/Adapter/MiGateway/MiGateway.php (subcarpeta, para adaptadores con su propio vendor/). Una subcarpeta de dos niveles no se descubre: el depth('== 0') del segundo Finder lo impide.

La resolución de clase está en getAdapterClassName() (L~445):

$class = "Payment_Adapter_{$pg->gateway}";
if (!class_exists($class)) {
    $nestedFile = Path::join(PATH_LIBRARY, 'Payment', 'Adapter', $pg->gateway, "{$pg->gateway}.php");
    $flatFile   = Path::join(PATH_LIBRARY, 'Payment', 'Adapter', "{$pg->gateway}.php");
    if ($this->filesystem->exists($nestedFile))      require_once $nestedFile;
    elseif ($this->filesystem->exists($flatFile))    require_once $flatFile;
}
return $class;
flowchart LR
    F["Finder sobre library/Payment/Adapter"] --> P["Archivo plano MiGateway.php"]
    F --> S["Subcarpeta MiGateway/MiGateway.php"]
    P --> DB["Fila en pay_gateway con gateway = MiGateway"]
    S --> DB
    DB --> G["getAdapterClassName"]
    G --> C["Payment_Adapter_MiGateway"]
    C --> R["require_once del fichero si la clase no existe"]
    R --> N["new Payment_Adapter_MiGateway con el config"]
    N --> D["setDi del contenedor Pimple"]

La tabla que guarda todo esto es pay_gateway. Columnas relevantes: id, name (título visible), gateway (el código, que es literalmente el nombre de clase sin el prefijo Payment_Adapter_), enabled, test_mode, config (JSON), accepted_currencies (JSON), allow_single y allow_recurrent.

El método install() (L148) crea el registro desactivado y vacío:

$new = $this->di['db']->dispense('PayGateway');
$new->name = $code;   $new->gateway = $code;
$new->enabled = 0;    $new->accepted_currencies = null;
$new->test_mode = 0;  $new->config = null;

Gotcha: el nombre del archivo, el sufijo de la clase y el valor de pay_gateway.gateway tienen que coincidir exactamente, en PascalCase. Si copias mipasarela.php y la clase se llama Payment_Adapter_MiPasarela, el descubrimiento la lista pero la instanciación falla con Payment gateway :adapter was not found.. Y tras copiar cualquier adaptador nuevo hay que limpiar la caché de Twig, como viste en el capítulo 3:

rm -rf /ruta/fossbilling/data/cache/*

Instalar y activar una pasarela desde la API de administración

En la interfaz esto es System → Payment gateways, con las plantillas src/modules/Invoice/templates/admin/mod_invoice_gateways.html.twig y mod_invoice_gateway.html.twig. Pero la API es más clara para entender qué pasa. Todos estos métodos están en src/modules/Invoice/Api/Admin.php y exigen el permiso invoice:manage_gateways:

Método APILíneaParámetros
invoice_gateway_get_available640ninguno; devuelve adaptadores presentes en disco no instalados
invoice_gateway_install653code (obligatorio)
invoice_gateway_get_list602paginación
invoice_gateway_get_pairs623ninguno
invoice_gateway_get668id
invoice_gateway_copy690id
invoice_gateway_update714id; opcionales title, config, accepted_currencies, enabled, allow_single, allow_recurrent, test_mode
invoice_gateway_delete740id

Un alta completa de Stripe en modo test, con clave de API de administración (TU_API_KEY_ADMIN es la clave que generaste en el panel de staff):

API=https://billing.example.com/api/admin/invoice
AUTH="TU_API_KEY_ADMIN:"

# 1. Adaptadores presentes en disco y no instalados todavia
curl -s -u "$AUTH" $API/gateway_get_available

# 2. Instalar: crea la fila en pay_gateway con enabled = 0
curl -s -u "$AUTH" -H 'Content-Type: application/json' \
  -d '{"code":"Stripe"}' $API/gateway_install

# 3. Configurar y activar en modo test
curl -s -u "$AUTH" -H 'Content-Type: application/json' -d '{
  "id": 3, "title": "Tarjeta Stripe", "test_mode": true, "enabled": true,
  "allow_single": true, "allow_recurrent": true,
  "config": {"test_pub_key":"pk_test_...","test_api_key":"sk_test_...","test_webhook_secret":"whsec_..."}
}' $API/gateway_update

invoice_gateway_copy duplica una pasarela existente: es lo que usas para tener dos configuraciones de Stripe, una por moneda o por marca. Recuérdalo, porque esa es exactamente la situación que rompía el enrutado de webhooks antes de 0.8.5.

La validación al activar: required_when y el error 819

Hasta 0.8.1 podías guardar una pasarela activada con la configuración a medias, y el fallo aparecía cuando un cliente intentaba pagar. Desde 0.8.2 (#3699) eso cambió: ServicePayGateway::update() (L211) instancia el adaptador antes de guardar si vas a activarlo.

if ($newEnabled) {
    $this->validateGatewayConfig($model, $mergedConfig, $newTestMode);
}

Y validateGatewayConfig() (L250) hace lo siguiente:

$adapterConfig = $config;
$adapterConfig['test_mode'] = $testMode;
try {
    $class = $this->getAdapterClassName($model);
    if (!class_exists($class)) return;
    new $class($adapterConfig);           // <-- el constructor valida
} catch (\Payment_Exception $e) {
    throw new \FOSSBilling\Exception($e->getMessage(), null, 819);
} catch (\Throwable $e) {
    throw new \FOSSBilling\Exception('Payment gateway configuration error: ' . $e->getMessage(), null, 819);
}

O sea: la validación real la hace el constructor del adaptador, y el servicio traduce cualquier Payment_Exception a una FOSSBilling\Exception con código 819. Si intentas activar Stripe en modo test sin test_api_key, la respuesta es literalmente:

The "Stripe" payment gateway is not fully configured. Please configure the Test API Key

La otra mitad del mecanismo es required_when en las definiciones de formulario, añadido también en 0.8.2. Un campo no es obligatorio siempre: lo es en función del par (enabled, test_mode). Eso permite tener rellenas solo las claves de test mientras pruebas, sin que el formulario te exija las de producción.

Stripe: claves live y test, publishable, secret y webhook signing secret

Payment_Adapter_Stripe::getConfig() (src/library/Payment/Adapter/Stripe.php L76-125) define seis campos, tres por modo:

return [
    'supports_one_time_payments' => true,
    'supports_subscriptions'     => true,
    'description' => 'You authenticate to the Stripe API by providing one of your API keys in the request. You can manage your API keys from your account.',
    'logo' => ['logo' => 'stripe.png', 'height' => '30px', 'width' => '65px'],
    'form' => [
        'pub_key'             => ['text', ['label' => 'Live Publishable Key:',        'required_when' => ['enabled' => true, 'test_mode' => false]]],
        'api_key'             => ['text', ['label' => 'Live Secret Key:',             'required_when' => ['enabled' => true, 'test_mode' => false]]],
        'webhook_secret'      => ['text', ['label' => 'Live Webhook signing secret:', 'required_when' => ['enabled' => true, 'test_mode' => false]]],
        'test_pub_key'        => ['text', ['label' => 'Test Publishable Key:',        'required_when' => ['enabled' => true, 'test_mode' => true]]],
        'test_api_key'        => ['text', ['label' => 'Test Secret Key:',             'required_when' => ['enabled' => true, 'test_mode' => true]]],
        'test_webhook_secret' => ['text', ['label' => 'Test Webhook signing secret:', 'required_when' => ['enabled' => true, 'test_mode' => true]]],
    ],
];

El constructor (L53-74) elige el juego de claves según test_mode y aborta si falta alguna:

if ($this->config['test_mode']) {
    if (!isset($this->config['test_api_key'])) throw new Payment_Exception(..., 4001);
    if (!isset($this->config['test_pub_key'])) throw new Payment_Exception(..., 4001);
    $this->stripe = new StripeClient($this->config['test_api_key']);
} else {
    if (!isset($this->config['api_key'])) throw new Payment_Exception(..., 4001);
    if (!isset($this->config['pub_key'])) throw new Payment_Exception(..., 4001);
    $this->stripe = new StripeClient($this->config['api_key']);
}

El código 4001 significa exactamente una cosa: pasarela mal configurada, falta una clave. Es el mismo patrón que verás en 3001 para registradores y 2001 para server managers.

Tres cosas que conviene tener claras:

Uno. La publishable key se usa en el navegador, con Stripe Elements. La secret key nunca sale del servidor. El webhook signing secret no es ninguna de las dos: lo generas en Stripe al dar de alta el endpoint, y empieza por whsec_.

Dos. El SDK va embebido. En 0.8.5 es stripe/stripe-php v21; en 0.8.4 era v20.3.0 y en 0.8.0, v20.2. No lo instalas tú.

Tres. Modo test y modo live tienen secretos de webhook distintos. Cruzarlos es la causa más frecuente de Invalid Stripe webhook signature.

El webhook de Stripe: firma HMAC obligatoria y los 12 eventos manejados

Desde 0.8.4 (#3962) la firma dejó de ser opcional. processWebhookEvent() (Stripe.php L514-604):

$rawBody   = $data['http_raw_post_data'] ?? '';
$sigHeader = $data['server']['HTTP_STRIPE_SIGNATURE'] ?? '';
$webhookSecret = $this->config['test_mode']
    ? ($this->config['test_webhook_secret'] ?? '')
    : ($this->config['webhook_secret'] ?? '');

if (empty($webhookSecret)) throw new FOSSBilling\Exception('Stripe webhook signing secret is not configured');
if (empty($sigHeader))     throw new FOSSBilling\Exception('Missing Stripe-Signature header');

try {
    $event = Stripe\Webhook::constructEvent($rawBody, $sigHeader, $webhookSecret);
} catch (UnexpectedValueException) {
    throw new FOSSBilling\Exception('Invalid Stripe webhook payload');
} catch (Stripe\Exception\SignatureVerificationException) {
    throw new FOSSBilling\Exception('Invalid Stripe webhook signature');
}

El comentario del propio código explica el porqué mejor que cualquier paráfrasis:

“Webhook events credit funds and mark invoices paid based on their contents, so a verified signature is mandatory. Without a signing secret configured there is no way to distinguish a genuine Stripe event from a forged one, so refuse to process the event at all rather than trusting an unsigned payload.”

Los cuatro mensajes de error de ese bloque son diagnósticos exactos, no genéricos. Memorízalos: Stripe webhook signing secret is not configured, Missing Stripe-Signature header, Invalid Stripe webhook payload, Invalid Stripe webhook signature.

La constante HANDLED_EVENT_TYPES (L28-41) lista 12 tipos:

'customer.subscription.created', 'customer.subscription.updated', 'customer.subscription.deleted',
'invoice.payment_succeeded', 'invoice.paid', 'invoice.payment_failed',
'invoice_payment.paid', 'invoice_payment.failed',
'payment_intent.succeeded', 'payment_intent.payment_failed',
'setup_intent.succeeded', 'setup_intent.setup_failed',

Cualquier evento fuera de esa lista se responde con 200 y su registro transaction se borra con $this->di['db']->trash($tx). La razón está documentada en el código: “Stripe sends many webhook events per payment cycle (e.g. invoice.created, charge.succeeded) that are not relevant to FOSSBilling.” Si no lo hiciera, tu listado de transacciones sería ilegible.

Gotcha: no configures el endpoint de Stripe para enviar “todos los eventos” y esperes ver algo en el panel. Verás 200 y silencio, porque la transacción se descarta. Selecciona en Stripe los 12 tipos de la lista.

Suscripciones Stripe: Setup Intents, cancelación real y aislamiento multi-gateway

Las suscripciones recurrentes de Stripe llegaron en 0.8.4 (#3455, #3857) implementadas con Setup Intents: primero se guarda el método de pago del cliente, y después se cobra contra él en cada periodo. Por eso setup_intent.succeeded y setup_intent.setup_failed están entre los eventos manejados.

Dos correcciones de 0.8.5 son relevantes para producción:

Uno. Antes de 0.8.5, cancelar una suscripción en FOSSBilling la cancelaba solo en la base de datos local. En Stripe seguía viva y seguía cobrando. #3968 lo arregló: ahora cancelSubscription(string $subscriptionId): void cancela de verdad en Stripe. Si estás por debajo de 0.8.5, revisa tu panel de Stripe a mano.

Dos. Si tienes dos filas en pay_gateway apuntando a la misma cuenta de Stripe, los webhooks llegaban a las dos y se acreditaban en la equivocada. #3969 introdujo eventBelongsToGateway() (L608): el objeto Stripe lleva metadata.gateway_id, y si no coincide con el gateway_id del callback, el evento se descarta. Para objetos antiguos sin ese metadato, se resuelve por la asociación local factura/suscripción.

La idempotencia en la creación del PaymentIntent es determinista:

$idempotencyKey = sprintf('one_time_invoice_%d_gateway_%d_%s',
    $invoice->id, $this->config['gateway_id'],
    hash('sha256', json_encode($intentParams, JSON_THROW_ON_ERROR)));
$intent = $this->stripe->paymentIntents->create($intentParams, ['idempotency_key' => $idempotencyKey]);

La clave incluye la factura, la pasarela y el hash de los parámetros. Si el cliente recarga la página de la factura tres veces, Stripe devuelve el mismo PaymentIntent en lugar de crear tres. Junto con #3975 (transacciones duplicadas en pagos únicos, corregido en 0.8.5), esto cierra el capítulo de duplicados en Stripe.

PayPal Payments Standard: _notify-validate y por qué VERIFIED no basta

Payment_Adapter_PayPalEmail::getConfig() (L36-57) tiene un único campo:

'form' => [
    'email' => ['text', ['label' => 'PayPal email address for payments', 'validators' => ['EmailAddress']]],
]

Y el endpoint se elige en serviceUrl() (L282):

test_mode = true  -> https://www.sandbox.paypal.com/cgi-bin/webscr
test_mode = false -> https://www.paypal.com/cgi-bin/webscr

Sí, cgi-bin/webscr. Es el PayPal Payments Standard clásico, no la REST API. El flujo es: formulario POST al endpoint, el cliente paga, PayPal envía un IPN a ipn.php, y FOSSBilling reenvía ese IPN de vuelta a PayPal con cmd=_notify-validate para preguntarle si es auténtico. _isIpnValid() (L291-326):

parse_str((string) $data['http_raw_post_data'], $post);   // usa el RAW, no $_POST, por encoding
$req = 'cmd=_notify-validate';
foreach ($post as $key => $value) {
    $value = urlencode(stripslashes($value));
    $req .= "&$key=$value";
}
if ($this->download($this->serviceUrl(), $req) !== 'VERIFIED') return false;

// VERIFIED solo prueba que PayPal origino la notificacion; verificar el payee
// para que un pago a otra cuenta no se pueda reproducir aqui.
$configured = strtolower(trim((string) $this->config['email']));
$payees = [strtolower(trim((string) ($post['receiver_email'] ?? ''))),
           strtolower(trim((string) ($post['business'] ?? '')))];
foreach ($payees as $payee) {
    if ($payee !== '' && hash_equals($configured, $payee)) return true;
}
return false;

Aquí está el punto que vale por todo el apartado: VERIFIED no basta. Ese estado solo prueba que PayPal originó la notificación, no que el dinero fuera a tu cuenta. Sin la segunda comprobación, cualquiera podría pagar un céntimo a su propia cuenta PayPal y reenviarte el IPN para acreditar una factura tuya. Por eso FOSSBilling compara receiver_email y business contra el email configurado, y lo hace con hash_equals(), comparación en tiempo constante.

Dos detalles operativos. El adaptador usa el RAW body en lugar de $_POST porque el encoding de PayPal no siempre sobrevive al parseo de PHP. Y el timeout del cliente HTTP de download() (L328) es de 600 segundos, un valor pensado para la lentitud histórica de webscr.

Los tipos de transacción manejados en processTransaction() son web_accept, subscr_payment y el resto de eventos subscr_*. En entorno de pruebas (Environment::isTesting()) la validación se salta. En 0.8.4 (#3927) se corrigió que IPNs subscr_signup repetidos duplicaran suscripciones.

ClientBalance y la pasarela Custom: pago con saldo y transferencia bancaria

Estas dos no hablan con ningún servidor externo, y su seguridad se juega en isIpnValid().

ClientBalance paga la factura con el saldo del cliente, ese client_balance que viste en el capítulo 6:

public function isIpnValid($data): bool { return $this->di['auth']->isClientLoggedIn(); }

No hay firma que verificar porque no hay tercero: la prueba de autenticidad es la sesión. processTransaction() añade dos comprobaciones de autorización más:

if ((int) $invoiceModel->client_id !== (int) $this->di['loggedin_client']->id)
    throw new Payment_Exception('You are not authorized to pay this invoice with client balance.');
if ((int) ($invoiceModel->gateway_id ?? 0) !== (int) $gateway_id)
    throw new Payment_Exception('Invoice is not configured to use this payment gateway.');

Además rechaza las facturas de tipo depósito con código 303 (no puedes recargar saldo pagando con saldo) y exige que la pasarela esté habilitada, código 301. En 0.8.5 se corrigieron sus flags de pago único (#3990).

Custom es la pasarela offline. Su getConfig() declara can_load_in_iframe, supports_one_time_payments y supports_subscriptions en true, y un formulario de dos textareas: single (“Enter Your Text for Single Payment Information”) y recurrent (“Enter Your Text for Subscription Information”).

Ese texto se renderiza con SystemService::renderAdapterTplString($tpl, ['invoice' => $invoice]), en un Twig sandboxed. Solo dispone de la variable invoice; dump(), imports y globals de API están bloqueados y producen un error de configuración en vez de renderizar. Un ejemplo válido:

Amount: {{ invoice.total }} {{ invoice.currency }}
Reference: {{ invoice.serie_nr }}

Y ahora la parte importante, isIpnValid() (L130):

private const string TRUSTED_SOURCE = 'admin';
public function isIpnValid(array $data): bool {
    return ($data['source'] ?? null) === self::TRUSTED_SOURCE;
}

Como ipn.php fija siempre 'source' => 'ipn', un callback externo nunca puede acreditar un pago Custom. Es imposible por construcción. Solo el flujo administrativo, que inyecta source = 'admin', lo consigue. Si alguien golpea tu ipn.php apuntando a una pasarela Custom, verás el mensaje Custom payment gateway callbacks must be confirmed by an administrator. y eso es el sistema funcionando bien.

La confirmación real se hace en Invoicing → Invoices → abrir la factura → Mark as paid, y para la pasarela Custom el transaction ID es obligatorio.

Las cuatro URLs que FOSSBilling inyecta en el adaptador y cuál pegar en el proveedor

Cuando el cliente abre /invoice/{hash}, ServicePayGateway::getPaymentAdapter() (L334-380) construye la configuración que recibirá el constructor del adaptador. No es solo lo que guardaste en el formulario:

$defaults['auto_redirect']          = false;
$defaults['gateway_id']             = (int) $pg->id;
$defaults['test_mode']              = $pg->test_mode;
$defaults['return_url']             = $this->getReturnUrl($pg, $model);
$defaults['cancel_url']             = $this->getCancelUrl($pg, $model);
$defaults['notify_url']             = $this->getCallbackUrl($pg, $model);
$defaults['redirect_url']           = $this->getCallbackRedirect($pg, $model);
$defaults['continue_shopping_url']  = $this->di['tools']->url('/order');
$defaults['single_page']            = true;
if ($model instanceof \Model_Invoice) {
    $defaults['thankyou_url'] = $this->di['url']->link("/invoice/thank-you/{$model->hash}", ['restore_token' => Tools::createSessionRestoreToken(session_id())]);
    $defaults['invoice_url']  = $this->di['tools']->url("/invoice/{$model->hash}");
}
$defaults['logo'] = null;
$config = array_merge($config, $defaults);   // <-- los defaults GANAN sobre el config guardado

Fíjate en la última línea. El array_merge pone los defaults después, así que los defaults ganan sobre el config guardado. No puedes sobrescribir notify_url ni gateway_id desde el formulario de la pasarela, y eso es deliberado.

Las dos URLs de callback las construyen getCallbackUrl() (para notify_url) y getCallbackRedirect() (para redirect_url), ambas sobre SYSTEM_URL . 'ipn.php?' . http_build_query($p). La primera lleva gateway_id y, si hay factura, invoice_id. La segunda añade además invoice_hash y redirect = 1, para que un solo hop notifique y devuelva el navegador a la factura.

Con url = https://billing.example.com/ en tu config.php (de ahí sale la constante SYSTEM_URL, definida en src/load.php L267), las URLs literales quedan así:

# Webhook / IPN generico de una pasarela con gateway_id = 3:
https://billing.example.com/ipn.php?gateway_id=3

# Webhook ligado a una factura concreta con invoice_id = 148:
https://billing.example.com/ipn.php?gateway_id=3&invoice_id=148

# Callback + redireccion del navegador a la factura:
https://billing.example.com/ipn.php?gateway_id=3&invoice_id=148&invoice_hash=EL_HASH&redirect=1

La que pegas en el panel de Stripe es la primera: genérica de la pasarela, sin invoice_id, porque un endpoint de Stripe sirve a todas las facturas y la factura se resuelve desde el payload firmado.

No hace falta que construyas la URL a mano: toApiArray() (L169) incluye 'callback' => $this->getCallbackUrl($model) cuando quien consulta es un Model_Admin, así que aparece en la ficha de la pasarela lista para copiar.

ipn.php por dentro: skip_validation, fastcgi_finish_request y la respuesta JSON

src/ipn.php son 93 líneas y merece la pena leerlas enteras, porque explican comportamientos que de otro modo parecen magia:

$invoiceID = $request->get('invoice_id');
if ($invoiceID !== null) {
    $invoiceID = filter_var($invoiceID, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
    if ($invoiceID === false) emitResponse(new JsonResponse([... 'Invalid invoice ID'], 400));
}
$gatewayID = $request->get('gateway_id');   // misma validacion, 400 si invalido

$rawBody = $request->getContent();

$ipn = [
    'invoice_id'         => $invoiceID,
    'gateway_id'         => $gatewayID,
    'source'             => 'ipn',
    'get'                => $request->query->all(),
    'post'               => $request->request->all(),
    'server'             => $request->server->all(),
    'http_raw_post_data' => $rawBody,
];

$contentType   = $request->headers->get('Content-Type', '');
$isJsonWebhook = str_contains((string) $contentType, 'application/json') && !empty($rawBody);
if ($isJsonWebhook) $ipn['skip_validation'] = true;

try {
    $service = $di['mod_service']('invoice', 'transaction');

    // Webhooks JSON (Stripe, etc.) requieren ACK 2xx rapido:
    if ($isJsonWebhook && function_exists('fastcgi_finish_request')) {
        $transactionId = $service->create($ipn);
        $response = $apiResponseFactory->create($transactionId);
        sendResponse($response);
        fastcgi_finish_request();
        $service->processAndCatchErrors((int) $transactionId);   // procesa en background
        return;
    }

    $output = $service->createAndProcess($ipn);
    $response = $apiResponseFactory->create($output);
} catch (Exception $e) {
    $response = $apiResponseFactory->create(null, $e);
}

// redireccion si la pasarela lo pide: el hash se sanea con preg_replace a alfanumerico
if ($request->query->has('redirect') && $request->query->has('invoice_hash')) {
    $hash = preg_replace('/[^a-zA-Z0-9]/', '', (string) $request->query->get('invoice_hash'));
    emitResponse((new ResponseFactory())->redirect($di['url']->link('invoice/' . $hash)));
}
emitResponse($response);

Cuatro detalles que hay que entender:

Uno. invoice_id y gateway_id se validan con filter_var(..., FILTER_VALIDATE_INT, ['min_range' => 1]). Si el proveedor añade un sufijo a la query string, la respuesta es HTTP 400 con Invalid invoice ID o Invalid gateway ID, y nunca se crea transacción.

Dos. Un Content-Type: application/json con body no vacío activa skip_validation = true. El registro transaction se crea sin exigir invoice_id ni gateway_id en la query. Es el camino de los webhooks estilo Stripe, donde la factura se resuelve después desde el payload ya verificado por firma.

Tres. fastcgi_finish_request() solo existe bajo PHP-FPM/FastCGI. Cuando está disponible y el webhook es JSON, FOSSBilling crea la transacción, responde 2xx, cierra la conexión y procesa en segundo plano. Sin PHP-FPM (mod_php, servidor embebido) el procesado es síncrono: Stripe espera, y si tarda demasiado reintenta el webhook. Este es un argumento duro para desplegar con PHP-FPM, tal como se montó en el capítulo 2.

Cuatro. La respuesta es el JSON estándar de la API ({"result": ..., "error": ...}). El proveedor solo ve un 200 con ese envoltorio, no un texto plano.

sequenceDiagram
    participant C as Cliente
    participant FB as FOSSBilling
    participant PG as Pasarela

    C->>FB: GET /invoice/{hash}
    FB->>FB: getPaymentAdapter inyecta las cuatro URLs
    FB->>PG: getHtml crea el PaymentIntent o el form
    FB-->>C: HTML con Stripe Elements o formulario POST
    C->>PG: confirma el pago
    PG-->>C: redirect a ipn.php con redirect=1
    PG->>FB: POST ipn.php?gateway_id=N con webhook JSON firmado
    FB->>FB: ServiceTransaction::create devuelve status received
    FB->>FB: claimForProcessing pasa a processing
    FB->>FB: adapter->processTransaction
    FB->>PG: verifica firma o _notify-validate
    FB->>FB: addFunds y markAsPaid con status processed
    FB-->>C: redirect a /invoice/{hash}

Compatibilidad BoxBilling: bb-ipn.php, los parámetros bb_* y qué replicar en nginx

FOSSBilling es un fork de BoxBilling, y las instalaciones migradas arrastran URLs de callback registradas en Stripe, PayPal y compañía apuntando a bb-ipn.php con parámetros bb_invoice_id y bb_gateway_id. En 0.8.3 (#3749, #3763) se añadió la traducción en src/.htaccess (L20-42):

    # from bb-ipn.php to ipn.php for users upgrading from BoxBilling.
    RewriteCond %{REQUEST_URI} ^/(bb-)?ipn\.php$ [NC]
    RewriteCond %{QUERY_STRING} (^|&)bb_invoice_id=([^&]*) [NC]
    RewriteRule ^(bb-)?ipn\.php$ $1ipn.php?%{QUERY_STRING}&invoice_id=%2 [NE]

    RewriteCond %{REQUEST_URI} ^/(bb-)?ipn\.php$ [NC]
    RewriteCond %{QUERY_STRING} (^|&)bb_gateway_id=([^&]*) [NC]
    RewriteRule ^(bb-)?ipn\.php$ $1ipn.php?%{QUERY_STRING}&gateway_id=%2 [NE]
    ...
    RewriteRule ^bb-ipn\.php$ /ipn.php [L]

Advertencia: eso es Apache. Si sirves con nginx o LiteSpeed, .htaccess no se lee y tienes que replicarlo a mano. Es la causa clásica del síntoma “el IPN antiguo devuelve 404 tras migrar de BoxBilling”, y el dinero se pierde en silencio porque el proveedor reintenta unas horas y se rinde.

Una traducción mínima para nginx, sobre la instalación del capítulo 2:

location = /bb-ipn.php {
    # traduce los parametros legacy y reenvia al endpoint actual
    if ($arg_bb_invoice_id) {
        rewrite ^ /ipn.php?$args&invoice_id=$arg_bb_invoice_id&gateway_id=$arg_bb_gateway_id? last;
    }
    rewrite ^ /ipn.php?$args last;
}

Verifícalo desde fuera antes de dar por buena la migración. Debe responder JSON, no 404 ni 403:

curl -i -X POST "https://billing.example.com/bb-ipn.php?bb_gateway_id=1&bb_invoice_id=1"
curl -i -X POST "https://billing.example.com/ipn.php?gateway_id=1&invoice_id=1"

Ciclo de vida de la transacción y las tres capas de deduplicación

src/library/Model/Transaction.php define cinco estados:

final public const string STATUS_RECEIVED   = 'received';
final public const string STATUS_APPROVED   = 'approved';
final public const string STATUS_PROCESSING = 'processing';
final public const string STATUS_PROCESSED  = 'processed';
final public const string STATUS_ERROR      = 'error';

En la interfaz aparecen como Received / Approved-Verified / Processing / Processed / Error, vía ServiceTransaction::getStatuses().

No confundas esos con los de Payment_Transaction, que son los que reporta la pasarela, más los tipos de operación:

STATUS_UNKNOWN='unknown'; STATUS_PENDING='pending'; STATUS_COMPLETE='complete';
STATUS_SUCCEEDED='succeeded'; STATUS_FAILED='failed';

TXTYPE_PAYMENT='payment'; TXTYPE_REFUND='refund';
TXTYPE_SUBSCR_CREATE='subscription_create'; TXTYPE_SUBSCR_CANCEL='subscription_cancel';
TXTYPE_UNKNOWN='unknown';

Un type que no encaje en ninguno de esos provoca el error 632, “tipo de transacción desconocido”, desde ServiceTransaction::process().

stateDiagram-v2
    [*] --> received: ipn.php crea el registro
    received --> processing: claimForProcessing con UPDATE atomico
    processing --> processed: processTransaction termina bien
    processing --> error: excepcion y markTransactionError
    error --> processing: reintento desde el admin o IPN repetido
    processing --> processing: reclaim tras PROCESSING_RECOVERY_TIMEOUT
    processed --> [*]

Antes de llegar a received, ServiceTransaction::create() (L114-200) aplica tres capas de deduplicación:

Uno. Por txn_id más gateway_id. Si ya existe una transacción processed con ese identificador externo, devuelve el id existente y no crea nada. Los candidatos a txn_id se buscan en este orden:

$data['txn_id'] ?? $data['post']['txn_id'] ?? $data['get']['txn_id']
    ?? $data['post']['payment_intent'] ?? $data['get']['payment_intent']

Dos. Por hash canónico del payload (ipn_hash). Se calcula sobre {source, get, post, http_raw_post_data, server} aplicando recursiveKsort para que el hash sea determinista independientemente del orden de las claves, y se busca por (gateway_id, ipn_hash). Gotcha: esta capa requiere que existan la columna transaction.ipn_hash y el índice transaction_ipn_hash_idx. Si faltan, supportsTransactionIpnHash() (L201) la desactiva silenciosamente y solo deja un warning en el log. Si migraste el esquema a mano en algún momento, compruébalo:

SHOW INDEX FROM transaction WHERE Key_name = 'transaction_ipn_hash_idx';
SHOW COLUMNS FROM transaction LIKE 'ipn_hash';

Tres. Por adaptador. Cada pasarela puede añadir su propia lógica. PayPalEmail::isIpnDuplicate() (L381) consulta:

SELECT id FROM transaction
WHERE txn_id = :transaction_id AND txn_status = :transaction_status
  AND type = :transaction_type AND amount = :transaction_amount
LIMIT 2

claimForProcessing(): el bloqueo atómico contra condiciones de carrera

Un webhook puede llegar dos veces a la vez. El navegador redirigido y el POST del proveedor pueden solaparse. Dos workers de PHP-FPM pueden tomar la misma transacción. Sin bloqueo, acreditas el saldo dos veces.

claimForProcessing(int $id): bool (L~485) resuelve esto con un único UPDATE condicional:

$affectedRows = $this->di['db']->exec(
    'UPDATE transaction SET status = ?, updated_at = ? WHERE id = ? AND (status IN (?, ?) OR (status = ? AND (updated_at IS NULL OR updated_at <= ?)))',
    [STATUS_PROCESSING, date('Y-m-d H:i:s'), $id,
     STATUS_RECEIVED, STATUS_ERROR,
     STATUS_PROCESSING, $this->getProcessingRecoveryThreshold()]
);
return $affectedRows > 0;

El truco es que la decisión y la mutación ocurren en la misma sentencia. Quien obtiene $affectedRows > 0 es el dueño del procesado; el resto recibe false y se retira. Las tres condiciones aceptadas:

  • received: el caso normal, se reclama de inmediato.
  • error: permite reintentar, desde el botón del admin o desde un IPN repetido de PayPal.
  • processing obsoleto: si updated_at es anterior al umbral PROCESSING_RECOVERY_TIMEOUT, se asume que el worker anterior murió y se reclama. Sin esto, un fallo del proceso dejaría la transacción bloqueada para siempre.

Este SQL tuvo un bug de agrupamiento del IN corregido en 0.8.3 (#3782). Si estás en 0.8.0-0.8.2, actualiza.

El complemento es markTransactionError(), que recarga la transacción desde la base de datos y solo la marca como error si no está ya processed, para que una excepción tardía no pise un cobro exitoso. Guarda el mensaje en transaction.error, el código en transaction.error_code y deja una línea en el canal de log: Failed to process transaction #%s: %s.

Advertencia operativa: transaction_process_all no forma parte del cron. Las transacciones en received se procesan cuando llega el IPN, o a mano desde Invoicing → Transactions, o llamando a la API invoice/transaction_process con el permiso invoice:manage_transactions. Si esperabas que el cron del capítulo 3 recogiera los rezagados, no lo hace.

Escribir tu propio Payment_Adapter: las dos convenciones vivas y el esqueleto completo

Antes del código, la fuente de confusión número uno: hay dos convenciones vivas en 0.8.5.

A) Extender Payment_AdapterAbstractB) Solo FOSSBilling\InjectionAwareInterface
Quién la usaMollie y la mayoría de adaptadores de tercerosStripe y Custom en el núcleo
Constructor padreExige return_url, cancel_url, notify_url y redirect_url; lanza 6001-6004 si faltanNo hay padre; gestionas tu propia configuración
Qué aportangetType(), getServiceUrl(), getInvoiceId(), setLog()/getLog(), getHttpClient(), getParam(), moneyFormat(), setTestMode()/getTestMode(), setOutput()/getOutput()Nada; más ligero

El núcleo no exige herencia. getPaymentAdapter() solo hace new $class($config) y, si el método existe, $adapter->setDi($this->di). La única comprobación posterior es method_exists($adapter, 'processTransaction'). La guía oficial en docs.fossbilling.org/extensions-and-development/guides/creating-a-payment-gateway/ dice que el adaptador “must implement FOSSBilling\InjectionAwareInterface” y omite la vía A; el código dice otra cosa.

Firmas que importan, en 0.8.5:

MétodoFirmaObligatorioQuién lo llama
__construct__construct($config)getPaymentAdapter() y validateGatewayConfig()
getConfigpublic static function getConfig(): arraygetAdapterConfig(), getFormElements(), getDescription()
setDi / getDisetDi(Pimple\Container $di): voidSí en la prácticagetPaymentAdapter() vía method_exists
getHtmlgetHtml(FOSSBilling\Api\Proxy $api_admin, int $invoice_id, bool $subscription): stringSí para mostrar el pagoControlador de factura del cliente
processTransactionprocessTransaction(FOSSBilling\Api\Proxy $api_admin, int $id, array $data, int $gateway_id), comprobado con method_existsServiceTransaction::processTransaction()
isIpnValidisIpnValid(array $data): boolConvención, no forzadoTu propio processTransaction()
getTypegetType(): string con TYPE_HTML, TYPE_FORM o TYPE_APINo; por defecto TYPE_FORMPlantillas
getInvoiceIdgetInvoiceId($data)No; por defecto $data['invoice_id'] ?? nullPre-procesado
setOutput / getOutputsetOutput(string $response): voidNoPasarelas que exigen un body concreto
cancelSubscriptioncancelSubscription(string $subscriptionId): voidSolo con suscripcionesServiceSubscription

El archivo va en src/library/Payment/Adapter/MiGateway.php (o MiGateway/MiGateway.php si trae dependencias). Las extensiones de tipo payment-gateway instaladas desde el directorio aterrizan en Path::join(PATH_LIBRARY, 'Payment', 'Adapter', ucfirst($id)) (src/modules/Extension/Service.php L720-741). Ojo con ese ucfirst(): el id blockonomics acaba en la carpeta Blockonomics.

Esqueleto completo, variante A:

<?php

declare(strict_types=1);

class Payment_Adapter_MiGateway extends Payment_AdapterAbstract implements FOSSBilling\InjectionAwareInterface
{
    protected ?Pimple\Container $di = null;
    protected array $config = [];

    public function setDi(Pimple\Container $di): void { $this->di = $di; }
    public function getDi(): ?Pimple\Container        { return $this->di; }

    /**
     * El padre valida return_url / cancel_url / notify_url / redirect_url
     * y lanza Payment_Exception con codigos 6001..6004.
     */
    public function __construct(array $config)
    {
        parent::__construct($config);
        $this->config = $config;

        $required = ['api_key' => 'API Key', 'merchant_id' => 'Merchant ID'];
        foreach ($required as $key => $label) {
            if (empty($this->config[$key])) {
                throw new Payment_Exception(
                    'The ":pay_gateway" payment gateway is not fully configured. Please configure the :missing',
                    [':pay_gateway' => 'MiGateway', ':missing' => $label],
                    4001
                );
            }
        }
    }

    /** Debe ser STATIC: se invoca sin instanciar. */
    public static function getConfig(): array
    {
        return [
            'supports_one_time_payments' => true,
            'supports_subscriptions'     => false,
            'can_load_in_iframe'         => true,
            'description' => 'Procesa pagos mediante MiGateway.',
            'logo' => ['logo' => 'migateway.png', 'height' => '30px', 'width' => '65px'],
            'form' => [
                'merchant_id'    => ['text',     ['label' => 'Merchant ID',  'required_when' => ['enabled' => true, 'test_mode' => false]]],
                'api_key'        => ['password', ['label' => 'Live API Key', 'required_when' => ['enabled' => true, 'test_mode' => false]]],
                'test_api_key'   => ['text',     ['label' => 'Test API Key', 'required_when' => ['enabled' => true, 'test_mode' => true]]],
                'webhook_secret' => ['text',     ['label' => 'Webhook signing secret']],
            ],
        ];
    }

    public function getType(): string { return Payment_AdapterAbstract::TYPE_HTML; }

    public function getServiceUrl(): string
    {
        return $this->getParam('test_mode')
            ? 'https://sandbox.migateway.example/checkout'
            : 'https://api.migateway.example/checkout';
    }

    /** HTML que ve el cliente en /invoice/{hash}. Escapa SIEMPRE cada valor. */
    public function getHtml(FOSSBilling\Api\Proxy $api_admin, int $invoice_id, bool $subscription): string
    {
        $invoiceModel   = $this->di['db']->load('Invoice', $invoice_id);
        $invoiceService = $this->di['mod_service']('Invoice');
        $total          = $invoiceService->getTotalWithTax($invoiceModel);

        $fields = [
            'merchant'   => $this->config['merchant_id'],
            'amount'     => $this->moneyFormat($total, $invoiceModel->currency),
            'currency'   => $invoiceModel->currency,
            'order_ref'  => (string) $invoiceModel->id,
            'notify_url' => $this->getParam('notify_url'),   // inyectada por el nucleo
            'return_url' => $this->getParam('return_url'),
            'cancel_url' => $this->getParam('cancel_url'),
        ];

        $esc  = fn ($v) => htmlspecialchars((string) $v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
        $form = sprintf('<form name="payment_form" method="post" action="%s">', $esc($this->getServiceUrl()));
        foreach ($fields as $k => $v) {
            $form .= sprintf('<input type="hidden" name="%s" value="%s" />', $esc($k), $esc($v));
        }

        return $form . '<button class="btn btn-primary" type="submit">Pagar</button></form>';
    }

    /**
     * $data = ['invoice_id','gateway_id','source','get','post','server','http_raw_post_data']
     */
    public function isIpnValid(array $data): bool
    {
        $raw       = (string) ($data['http_raw_post_data'] ?? '');
        $signature = (string) ($data['server']['HTTP_X_MIGATEWAY_SIGNATURE'] ?? '');
        $secret    = (string) ($this->config['webhook_secret'] ?? '');

        if ($secret === '' || $signature === '' || $raw === '') {
            return false;
        }

        return hash_equals(hash_hmac('sha256', $raw, $secret), $signature);
    }

    public function processTransaction(FOSSBilling\Api\Proxy $api_admin, int $id, array $data, int $gateway_id): bool
    {
        if (!$this->isIpnValid($data)) {
            throw new Payment_Exception('IPN is invalid');
        }

        $tx = $this->di['db']->getExistingModelById('Transaction', $id);
        if ($tx->status === Model_Transaction::STATUS_PROCESSED) {
            return true;                                    // idempotencia: nunca reprocesar
        }

        $payload   = json_decode((string) $data['http_raw_post_data'], true, 512, JSON_THROW_ON_ERROR);
        $invoiceId = $tx->invoice_id ?: ($data['get']['invoice_id'] ?? ($payload['order_ref'] ?? null));
        if (!$invoiceId) {
            throw new Payment_Exception('No se pudo determinar la factura del callback');
        }
        $invoice = $this->di['db']->getExistingModelById('Invoice', (int) $invoiceId);

        // VERIFICACION SERVIDOR-A-SERVIDOR del importe y la moneda
        $invoiceService = $this->di['mod_service']('Invoice');
        $expected = $invoiceService->getTotalWithTax($invoice);
        if (abs(((float) $payload['amount']) - $expected) > 0.001
            || strtoupper((string) $payload['currency']) !== strtoupper((string) $invoice->currency)) {
            throw new Payment_Exception('El importe o la moneda del callback no coinciden con la factura');
        }

        // reclamar el procesado de forma atomica
        $transactionService = $this->di['mod_service']('Invoice', 'Transaction');
        if (!$transactionService->claimForProcessing((int) $tx->id)) {
            return false;                                   // otro worker ya lo tiene
        }

        $tx->invoice_id = (int) $invoice->id;
        $tx->txn_id     = (string) $payload['transaction_id'];
        $tx->txn_status = (string) $payload['status'];
        $tx->type       = Payment_Transaction::TXTYPE_PAYMENT;
        $tx->amount     = $expected;
        $tx->currency   = $invoice->currency;

        if (($payload['status'] ?? '') === 'paid') {
            $clientService = $this->di['mod_service']('Client');
            $client        = $clientService->get(['id' => $invoice->client_id]);

            $clientService->addFunds($client, $expected, 'MiGateway transaction No: ' . $tx->txn_id, []);
            $invoiceService->payInvoiceWithCredits($invoice);
            $invoiceService->doBatchPayWithCredits(['client_id' => $invoice->client_id]);

            $tx->status = Model_Transaction::STATUS_PROCESSED;
            $tx->error  = '';
            $tx->error_code = null;
        } else {
            $tx->status = Model_Transaction::STATUS_ERROR;
            $tx->error  = 'Pago no completado: ' . ($payload['status'] ?? 'desconocido');
        }

        $tx->updated_at = date('Y-m-d H:i:s');
        $this->di['db']->store($tx);
        $this->setOutput('OK');    // solo si la pasarela exige un body concreto

        return true;
    }

    /** Opcional: extraer el invoice_id del IPN antes de procesar. */
    public function getInvoiceId($data)
    {
        return $data['post']['order_ref'] ?? $data['get']['invoice_id'] ?? null;
    }
}

Fíjate en el patrón de acreditación: no se marca la factura como pagada directamente. Se añade el importe al saldo del cliente con addFunds() y después se paga la factura con ese saldo mediante payInvoiceWithCredits(). Así el sobrante queda como crédito en lugar de perderse, y el flujo coincide con el que describe el capítulo 8.

Verificación servidor-a-servidor: importe, moneda, idempotencia y unidades menores

Cinco reglas que el propio código del núcleo impone, y que tu adaptador debe respetar:

Uno. Verifica siempre en servidor. Nunca creas un dato que venga del navegador del cliente. Un POST a ipn.php lo puede hacer cualquiera.

Dos. Valida importe y moneda contra la factura real antes de acreditar nada, como en el bloque de arriba. El módulo Invoice ya aplica su propia comprobación con validatePaymentAmount(), con tolerancia de 0.01; un pago por debajo de lo esperado produce Payment amount does not match the expected invoice total. Expected :expected, received :received. y un sobrepago mayor de 1.00 genera un aviso, porque suele significar un pago aplicado a la factura equivocada.

Tres. Detecta duplicados revisando $tx->status y consultando por txn_id más gateway_id. Las tres capas del núcleo ayudan, pero la capa del adaptador es la que conoce la semántica de tu proveedor.

Cuatro. Reclama con claimForProcessing() antes de mutar cualquier saldo, si el flujo puede concurrir. Y sí, puede.

Cinco. Escapa todo en el HTML que generes: htmlspecialchars($v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'). Usa $this->getHttpClient() en vez de cURL crudo, porque respeta la interfaz de salida configurada con BIND_TO. Y loguea con contexto, sin volcar secretos.

Sobre monedas hay dos detalles fáciles de pasar por alto. Si accepted_currencies es null o está vacío, se aceptan todas las monedas dadas de alta en el módulo Currency:

if ($model->accepted_currencies === null || empty($model->accepted_currencies)) {
    return array_keys($currencyRepository->getPairs());
}
return json_decode($model->accepted_currencies ?? '', true);

Y el formateo. Payment_AdapterAbstract::moneyFormat() (L171) usa Symfony\Component\Intl\Currencies::getFractionDigits() con fallback a 2 decimales si ICU no conoce la moneda. Stripe trabaja en unidades menores:

public function getAmountInMinorUnits(Model_Invoice $invoice): int {
    $amount = $invoiceService->getTotalWithTax($invoice);
    $multiplier = 10 ** $this->getCurrencyFractionDigits($invoice->currency);
    return (int) round($amount * $multiplier);
}

Ese 10 ** fractionDigits es lo que hace que JPY (0 decimales) y las monedas de 3 decimales funcionen bien. getAmountInCents() sigue existiendo como alias de compatibilidad, pero si multiplicas por 100 a mano en tu adaptador, cobrarás cien veces de más en yenes.

Depurar un pago que no acredita: árbol de decisión, consultas SQL y tabla de errores

El diagnóstico empieza siempre por la misma pregunta: ¿existe la fila en transaction?

flowchart TD
    A["El pago no acredita"] --> B{"Existe fila en la tabla transaction?"}
    B -- No --> C["El webhook nunca llego"]
    C --> C1["Revisar los intentos y codigos HTTP en el panel del proveedor"]
    C --> C2["curl al endpoint desde fuera de la red"]
    C --> C3["Revisar reescrituras del webserver y WAF o Cloudflare bloqueando POST"]
    B -- Si --> D{"Cual es el status?"}
    D -- received --> E["Nunca se proceso: lanzar transaction_process"]
    D -- processing --> F["Colgado: esperar el recovery timeout o forzar"]
    D -- error --> G["Leer las columnas error y error_code"]
    D -- processed --> H["Se proceso: comprobar si la factura quedo paid"]
    G --> G1["4001 significa falta de configuracion"]
    G --> G2["Invalid Stripe webhook signature"]
    G --> G3["IPN is invalid en PayPal"]

Las consultas y los logs que necesitas. La columna transaction.ipn guarda el JSON completo {source, get, post, http_raw_post_data, server}: con eso reconstruyes el webhook exacto y lo reproduces con curl. Los canales de log relevantes son application y billing, con RotatingFileHandler y retención de 90 archivos.

# Estado de las ultimas transacciones
mysql -e "SELECT id,gateway_id,invoice_id,txn_id,txn_status,status,error_code,LEFT(error,80) AS err,created_at \
          FROM transaction ORDER BY id DESC LIMIT 20" fossbilling

# Payload crudo de un IPN concreto y pasarelas instaladas
mysql -e "SELECT ipn FROM transaction WHERE id=123\G" fossbilling
mysql -e "SELECT id,name,gateway,enabled,test_mode FROM pay_gateway" fossbilling

tail -f /ruta/fossbilling/data/log/application/application-$(date +%Y-%m-%d).log
tail -f /ruta/fossbilling/data/log/billing/billing-$(date +%Y-%m-%d).log
tail -f /ruta/fossbilling/data/log/php_error.log

Y para ver el detalle de los errores de Stripe, activa el modo depuración en config.phpdesactívalo después en producción:

'debug_and_monitoring' => [
    'debug' => true,
    'log_stacktrace' => true,
    'stacktrace_length' => 25,
    'report_errors' => false,
],

Con DEBUG activo, Stripe::logError() (L204-218) vuelca el JSON del error a error_log().

Para reprocesar: Invoicing → Transactions → Process en la fila, o la API POST /api/admin/invoice/transaction_process con {"id": <id>}. La versión masiva es transaction_process_all, que recorre getReceived():

SELECT m.* FROM transaction AS m
WHERE m.status = :received_status
   OR (m.status = :processing_status AND (m.updated_at IS NULL OR m.updated_at <= :processing_retry_after))
ORDER BY m.id DESC

Errores comunes y diagnóstico

SíntomaCausaDiagnóstico o arreglo
Invalid invoice ID / Invalid gateway ID con HTTP 400La query string del callback trae un valor no entero o menor que 1El proveedor añade un sufijo. Revisa filter_var en ipn.php L27/36
Invalid payment gatewaygateway_id apunta a un PayGateway inexistenteLog de warning IPN with invalid gateway_id rejected: N. Verifica con SELECT id, gateway FROM pay_gateway
Stripe webhook signing secret is not configuredFalta webhook_secret o test_webhook_secretObligatorio desde 0.8.4 (#3962). Antes se aceptaban payloads sin firma
Missing Stripe-Signature headerLlamada manual a ipn.php o un proxy que elimina cabecerasRevisa que el reverse proxy no filtre Stripe-Signature
Invalid Stripe webhook signatureSecreto de test usado en modo live o al revés; o el webserver modificó el bodyCompara test_mode de la pasarela con el endpoint dado de alta en Stripe
Invalid Stripe webhook payloadEl body no es JSON válidoAlgo entre Stripe y PHP está reescribiendo el cuerpo
IPN is invalid en PayPal_notify-validate no devolvió VERIFIED, o receiver_email / business no coinciden con el email configuradoCompara el email de la pasarela con el de la cuenta que recibió el pago
Custom payment gateway callbacks must be confirmed by an administrator.Alguien golpeó ipn.php para una pasarela CustomComportamiento correcto: solo source='admin' acredita
701 Could not determine transaction origin. Transaction payment gateway is unknown.transaction.gateway_id es NULLViene de un webhook JSON con skip_validation cuyo adaptador no fijó el gateway
704 Cannot handle transaction received from unknown payment gateway: :idEl PayGateway se borró después de crear la transacciónRecrea la pasarela con el mismo id o corrige transaction.gateway_id
705 Payment adapter :adapter does not support action processTransactionEl adaptador no implementa el métodoLa comprobación es method_exists; impleméntalo
Payment gateway :adapter was not found.Archivo del adaptador borrado o mal nombradoEl nombre de archivo debe coincidir con pay_gateway.gateway y la clase ser Payment_Adapter_<gateway>
819 al guardar la pasarelavalidateGatewayConfig(): faltan claves para el test_mode actualRellena los campos marcados con required_when
632 tipo de transacción desconocidoEl type no está entre las constantes TXTYPE_*Revisa qué valor escribe tu adaptador en $tx->type
4001 al activarFalta una clave de API en el constructor del adaptadorEl mensaje indica exactamente qué campo falta
301 / 303 en ClientBalancePasarela deshabilitada / factura de depósitoNo se puede pagar una recarga de saldo con saldo
Timeout del webhook y Stripe reintentaNo hay fastcgi_finish_request() porque no estás en PHP-FPMMigra a PHP-FPM
Transacciones duplicadas en pagos únicos StripeBug corregido en 0.8.5 (#3975)Actualiza
Webhooks acreditados en la pasarela equivocadaDos PayGateway comparten cuenta Stripe. Corregido en 0.8.5 (#3969) con eventBelongsToGateway()Actualiza
Suscripción cancelada en FOSSBilling sigue viva en StripeBug corregido en 0.8.5 (#3968)Actualiza y revisa Stripe a mano
IPN subscr_signup de PayPal duplica suscripcionesCorregido en 0.8.4 (#3927)Actualiza
404 en bb-ipn.php tras migrar de BoxBilling.htaccess no se aplica en nginx ni LiteSpeedReplica las reescrituras a mano
El adaptador nuevo no aparece en la listaCaché de Twigrm -rf data/cache/*
La deduplicación por hash no actúaFaltan la columna transaction.ipn_hash o el índice transaction_ipn_hash_idxSolo deja un warning en el log; compruébalo con SHOW INDEX FROM transaction

Un aviso final sobre estabilidad de rutas: el issue #4121, abierto, propone mover pasarelas, registradores y server managers de src/library/{Payment,Registrar,Server} a src/extensions. Si se implementa, todas las rutas de este capítulo cambian. Antes de invertir en un adaptador propio a largo plazo, mira cómo va ese hilo.

Lo que queda operativo

Tienes Stripe configurado con sus tres claves por modo y su webhook firmado, PayPal Payments Standard con la verificación de destinatario que evita el fraude clásico, la pasarela Custom para transferencias con instrucciones renderizadas en Twig sandboxed, y ClientBalance para pagar con saldo. Sabes qué URL exacta pegar en cada proveedor, qué hace ipn.php línea a línea, por qué PHP-FPM importa aquí más que en ningún otro punto de la instalación, y cómo se protege el sistema de webhooks duplicados con tres capas de deduplicación más un UPDATE atómico. Y tienes un Payment_Adapter propio que verifica firma, importe y moneda antes de tocar un solo céntimo de saldo.

El dinero ya entra. Lo siguiente es que el servicio salga: en el capítulo 11 verás los server managers, cómo FOSSBilling crea una cuenta de hosting en cPanel, Plesk, DirectAdmin, HestiaCP o CWP en cuanto la factura pasa a paid, y cómo escribir el tuyo con la misma disciplina que acabas de aplicar a las pasarelas.