Facturación, pagos y cobranza: facturas, transacciones, créditos y reembolsos
Facturación, pagos y cobranza: facturas, transacciones, créditos y reembolsos
En el capítulo 7 dejaste el pedido corriendo: se creó en pending_setup, se aprovisionó, pasó a active y tiene un expires_at que se mueve en cada renovación. Pero un pedido activo no cobra nada por sí solo. Lo que cobra es la factura, y la factura es una máquina aparte, con su propio ciclo, sus propios lotes de cron y sus propias trampas.
La más cara de todas: cuando un cliente te dice “ya pagué” y en tu panel la factura sigue en unpaid, el problema casi nunca está en la factura. Está en la fila de la tabla transaction, en el estado en que se quedó y en la razón exacta por la que no llegó a ejecutar markAsPaid(). Sin entender ese camino completo, la respuesta operativa acaba siendo marcar la factura a mano y rezar para que no se duplique el cobro.
Este capítulo recorre el motor entero de src/modules/Invoice/ en FOSSBilling 0.8.5: cómo se crea una factura y qué se congela en ese instante, las cuatro condiciones del SQL que deciden si se emite la renovación, cómo funcionan los recordatorios y su candado contra los crons solapados, cómo se paga con saldo, cómo viaja una transacción desde received hasta processed con tres capas de deduplicación por medio, y qué documento genera cada uno de los tres modos de reembolso. Al final tendrás una tabla de diagnóstico que va de “el pago no acredita” a la fila concreta de base de datos que hay que mirar.
Una sola tabla para proforma y factura: qué cambia exactamente al pagar
Esta es la primera idea que hay que desmontar, porque contradice a casi todos los sistemas de facturación con los que habrás trabajado: FOSSBilling no tiene una tabla de proformas y otra de facturas. Es la misma fila.
En Invoice\Service::generateForOrder() la variable local se llama literalmente $proforma, pero acaba escribiendo en la tabla invoice. Lo que distingue una proforma de una factura definitiva es una combinación de tres columnas:
| Momento | status | approved | serie | paid_at | currency_rate |
|---|---|---|---|---|---|
| Recién creada | unpaid | 0 | valor de invoice_series (semilla FOSS) | NULL | tasa vigente |
| Aprobada | unpaid | 1 | igual | NULL | igual |
| Pagada | paid | 1 | valor de invoice_series_paid | now | congelado |
Es decir: al pagarse, la serie se reemplaza. Una factura que nació como FOSS-00042 puede terminar como INV-00042 si eso es lo que configuraste en invoice_series_paid. El número (invoice.nr) no cambia; el prefijo textual sí.
Los estados posibles están en src/library/Model/Invoice.php:
final public const string STATUS_PAID = 'paid';
final public const string STATUS_UNPAID = 'unpaid';
final public const string STATUS_REFUNDED = 'refunded';
final public const string STATUS_CANCELED = 'canceled';
stateDiagram-v2
[*] --> unpaid_no_aprobada: prepareInvoice / generateForOrder
unpaid_no_aprobada --> unpaid_aprobada: approveInvoice o invoice_auto_approval
unpaid_aprobada --> paid: markAsPaid
note right of paid
serie = invoice_series_paid
approved = 1
paid_at = now
currency_rate congelado
countIncome llena base_income
end note
paid --> refunded: refundInvoice
unpaid_no_aprobada --> canceled: invoice update status canceled
unpaid_aprobada --> canceled: invoice update status canceled
unpaid_no_aprobada --> [*]: purga por remove_after_days
unpaid_aprobada --> [*]: purga por remove_after_days
Advertencia: el parámetro remove_after_days (Invoice Settings, etiqueta Remove Unpaid Invoices After) tiene semilla 0, que significa conservar siempre. Si lo pones a un número, las facturas impagadas más antiguas que ese umbral desaparecen. No es un archivado: es un borrado.
Las columnas de la tabla invoice que importan
Vale la pena tener presente el esquema real, porque casi todo el diagnóstico de este capítulo se hace con SELECT sobre estas columnas:
| Grupo | Columnas |
|---|---|
| Identidad del documento | serie, nr, hash (UNIQUE), hash_expires_at |
| Dinero | currency, currency_rate, credit, base_income, base_refund, refund |
| Texto | notes, text_1, text_2 |
| Estado y ciclo | status, approved, due_at, reminded_at, paid_at |
| Vendedor congelado | seller_company, seller_company_vat, seller_company_number, seller_address, seller_phone, seller_email |
| Comprador congelado | buyer_first_name, buyer_last_name, buyer_company, buyer_company_vat, buyer_address, buyer_city, buyer_state, buyer_country, buyer_zip, buyer_phone, buyer_email |
| Cobro | gateway_id |
| Impuesto congelado | taxname, taxrate |
Y un índice que explica por qué los lotes de recordatorio son rápidos y por qué filtran siempre por approved:
invoice_status_approved_due_at_idx (status, approved, due_at)
setInvoiceDefaults(): todo lo que se congela en el momento de crear
Invoice\Service::setInvoiceDefaults() es el método que convierte una fila vacía en un documento. Congela, en ese instante y para siempre:
- Datos del vendedor: se copian del array que devuelve
Box\Mod\System\Service::getCompany()a las columnasseller_*. - Datos del comprador: nombre, empresa, VAT y dirección completa del cliente, a las columnas
buyer_*. due_at: la fecha de vencimiento (siguiente sección).serieynr: el prefijo deinvoice_seriesy el siguiente número del contador.hash:bin2hex(random_bytes(random_int(15, 30))), es decir entre 30 y 60 caracteres hexadecimales.taxnameytaxrate: la tasa resuelta para ese cliente en ese momento.notes: el contenido del parámetroinvoice_default_note, que admite markdown.
La palabra clave es congelar. Nada de esto se recalcula después. Si cambias el nombre de tu empresa mañana, las facturas de hoy siguen con el nombre viejo; si creas la regla de IVA después de emitir, las facturas emitidas conservan taxrate = 0. Ese es el motivo por el que el capítulo 4 insiste en dejar cerrados los datos de empresa, monedas e impuestos antes de emitir la primera factura.
Sobre la numeración, el detalle que sorprende: hay un único contador global, el parámetro de sistema invoice_starting_number. No hay un contador por serie.
$next_nr = $systemService->getParamValue('invoice_starting_number');
// fallback: MAX(nr)+1 leyendo la última factura con nr no nulo
$systemService->setParamValue('invoice_starting_number', intval($next_nr) + 1);
return $next_nr;
invoice_number_padding (default 5, mínimo 0, máximo 20) sólo afecta a la presentación en getInvoiceNumberPadding(). La columna invoice.nr es varchar(255) y guarda el número sin rellenar.
Aprobación automática y por qué approved=0 te deja sin recordatorios
El parámetro invoice_auto_approval viene sembrado a 1 y controla _isAutoApproved(). Si lo apagas, cada factura nace con approved = 0 y necesita una llamada explícita a invoice/approve para pasar a 1.
Esto suena inofensivo hasta que miras los SQL de los lotes de cobranza. Todos filtran por approved = 1. Una factura con approved = 0:
- no entra en
fireDueReminderEvents(), ni en la rama de antes del vencimiento ni en la de después; - no dispara
onEventBeforeInvoiceIsDuenionEventAfterInvoiceIsDue; - por tanto no genera ningún email de recordatorio, aunque
invoice_reminder_after_due_daysesté perfectamente configurado.
Gotcha: apagar la aprobación automática “para revisar las facturas antes de enviarlas” es la causa número uno de “los recordatorios no funcionan”. Si vas a apagarla, asume que la aprobación pasa a ser un paso operativo obligatorio, no una revisión opcional.
Comprobación rápida en base de datos:
SELECT id, serie, nr, status, approved, due_at, reminded_at, paid_at
FROM invoice
WHERE status = 'unpaid'
ORDER BY due_at ASC
LIMIT 20;
Si ves approved = 0 en filas antiguas, ahí tienes el problema.
Los eventos disponibles alrededor de la aprobación son onBeforeAdminInvoiceApprove y onAfterAdminInvoiceApprove; puedes engancharte a ellos con el mecanismo de hooks que se ve en el capítulo 14.
Líneas de factura: los tipos, las tareas y los estados
Cada línea es una fila de invoice_item, modelada en src/library/Model/InvoiceItem.php. Tiene tres ejes independientes, y confundirlos hace que el comportamiento parezca aleatorio.
| Eje | Valores | Para qué sirve |
|---|---|---|
type | deposit, custom, order, hook_call | Qué representa la línea |
task | void, activate, renew | Qué hay que ejecutar cuando la factura se pague |
status | pending_payment, pending_setup, executed | En qué punto de esa ejecución está |
Los tipos, uno a uno. order: la línea está ligada a un pedido concreto vía rel_id, y lleva task = activate en la primera compra y task = renew en las renovaciones. deposit: recarga de saldo, sin nada que activar detrás; es también el tipo que no se puede pagar con créditos, y ya veremos por qué. custom: un concepto libre que añade el staff, como una hora de trabajo o una migración, sin tarea asociada. hook_call: línea cuya ejecución dispara un hook, para que un módulo propio haga algo al cobrarse.
El motor que recorre las líneas al pagar vive en src/modules/Invoice/ServiceInvoiceItem.php y se llama desde el lote invoice_batch_activate_paid. Una línea con task = activate y un producto con setup = after_payment desemboca en activateOrder(); una con task = renew desemboca en renewOrder(), previo unsuspendFromOrder() si el pedido estaba suspendido.
Borrar una línea suelta: invoice/item_delete.
Generación de facturas de renovación: las cuatro condiciones del SQL
Aquí está el diagnóstico número uno de todo el módulo. El lote invoice_batch_generate llama a Invoice\Service::generateInvoicesForExpiringOrders(), que a su vez usa Order\Service::getSoonExpiringActiveOrdersQuery():
SELECT co.* FROM client_order co
LEFT JOIN invoice i ON i.id = co.unpaid_invoice_id AND i.status = 'unpaid'
WHERE co.status = 'active'
AND co.invoice_option = 'issue-invoice'
AND co.period IS NOT NULL
AND co.expires_at IS NOT NULL
AND i.id IS NULL
AND NOT EXISTS (
SELECT 1 FROM invoice_item pi
INNER JOIN invoice p ON p.id = pi.invoice_id
WHERE pi.rel_id = co.id AND pi.type = 'order' AND pi.task = 'renew'
AND pi.status != 'executed' AND p.status = 'paid')
HAVING DATEDIFF(co.expires_at, NOW()) <= :days_until_expiration
ORDER BY co.client_id DESC
El parámetro :days_until_expiration sale de invoice_issue_days_before_expire, con default 14.
flowchart TD
A["Pedido candidato en client_order"] --> B{"status = active"}
B -- No --> X["No se emite factura"]
B -- Si --> C{"invoice_option = issue-invoice"}
C -- No --> X
C -- Si --> D{"period IS NOT NULL"}
D -- No --> X
D -- Si --> E{"expires_at IS NOT NULL"}
E -- No --> X
E -- Si --> F{"Ya hay factura unpaid en unpaid_invoice_id"}
F -- Si --> X
F -- No --> G{"Existe linea renew no ejecutada en factura pagada"}
G -- Si --> X
G -- No --> H{"DATEDIFF expires_at menos ahora <= invoice_issue_days_before_expire"}
H -- No --> W["Todavia no toca"]
H -- Si --> I["generateForOrder crea proforma con linea task = renew"]
I --> J["approveInvoice y email mod_invoice_created"]
Las cuatro condiciones duras, en el orden en que conviene comprobarlas:
Uno. El pedido debe estar en active. Un pedido suspended no genera factura de renovación: si suspendiste por impago, no quieres emitir una segunda factura encima.
Dos. invoice_option = 'issue-invoice'. El otro valor posible es no-invoice, etiquetado en el panel como Issue Invoices Manually, y con él el pedido nunca renovará solo.
Tres. period IS NOT NULL. Un producto de pago único no tiene periodo y por definición no renueva.
Cuatro. expires_at IS NOT NULL. Sin fecha de caducidad no hay nada que anticipar.
Y las dos guardas anti-duplicado: no se emite si ya existe una factura unpaid referenciada desde co.unpaid_invoice_id, ni si hay una línea renew pendiente en una factura ya pagada.
Consulta de diagnóstico directa:
SELECT id, client_id, status, invoice_option, period, expires_at, unpaid_invoice_id,
DATEDIFF(expires_at, NOW()) AS dias
FROM client_order
WHERE id = 42;
Si invoice_option no es issue-invoice, period es NULL o expires_at es NULL, ya sabes por qué no se emitió nada. Los eventos asociados son onBeforeAdminGenerateRenewalInvoice y onAfterAdminGenerateRenewalInvoice, y hay un endpoint para forzar la emisión de una renovación concreta: invoice/renewal_invoice.
due_at, invoice_due_days y su relación con expires_at del pedido
due_at se calcula en setInvoiceDefaults() como now + invoice_due_days. El parámetro viene sembrado a 5, pero el fallback en código si el valor no es numérico es 1 día, no 5. Un campo vaciado por accidente en el formulario te deja facturas con un día de plazo.
En generateForOrder() hay una regla adicional que cambia el resultado por completo:
- si se pasa
due_days > 0explícitamente, se usa ese valor; - si no, y el pedido tiene
expires_at, entoncesdue_at = order.expires_at.
Esto es más inteligente de lo que parece: la factura de renovación emitida 14 días antes vence exactamente el día en que el servicio caduca, no 5 días después de emitirse. El cliente tiene los 14 días completos y el vencimiento coincide con el corte.
El malentendido clásico: due_at de la factura no suspende nada. Lo que dispara order_batch_suspend_expired es client_order.expires_at, no invoice.due_at. Si configuras 30 días de plazo de pago sobre un pedido mensual, el servicio se suspende igual el día que caduca el pedido, con la factura todavía dentro de plazo. Los dos relojes son independientes y hay que alinearlos a mano.
SELECT i.id, i.due_at, co.id AS order_id, co.expires_at
FROM invoice i
JOIN invoice_item ii ON ii.invoice_id = i.id AND ii.type = 'order'
JOIN client_order co ON co.id = ii.rel_id
WHERE i.status = 'unpaid';
Si due_at es sistemáticamente posterior a expires_at, tienes servicios que se suspenden con facturas vigentes.
Recordatorios antes y después del vencimiento: el candado reminded_at y el throttling diario
Los recordatorios son dos mecanismos encadenados: un lanzador de eventos y unos listeners que deciden si toca enviar.
El lanzador es fireDueReminderEvents(), con dos consultas:
-- antes del vencimiento -> onEventBeforeInvoiceIsDue
SELECT id, DATEDIFF(due_at, NOW()) AS days_left
FROM invoice
WHERE status = 'unpaid' AND approved = 1 AND due_at > NOW();
-- después del vencimiento, incluido el mismo día -> onEventAfterInvoiceIsDue
SELECT id, ABS(DATEDIFF(due_at, NOW())) AS days_passed
FROM invoice
WHERE status = 'unpaid' AND approved = 1
AND ((due_at < NOW()) OR (ABS(DATEDIFF(due_at, NOW())) = 0));
Los intervalos configurados están en dos parámetros que aceptan listas separadas por comas, parseadas por parseInvoiceReminderIntervals():
| Parámetro | Etiqueta UI | Semilla | Plantilla de email |
|---|---|---|---|
invoice_reminder_before_due_days | Payment Reminder Days Before Due | vacío = desactivado | mod_invoice_payment_reminder |
invoice_reminder_after_due_days | Payment Reminder Days After Due | 5 | mod_invoice_due_after |
Un valor como 14, 7, 1 en el primero envía tres avisos previos. Vacío significa desactivado, no “usar el default”.
Ahora la parte fina. Un cron que tarda más de lo previsto puede solaparse con el siguiente, y ambos verían la misma factura elegible. La protección es el candado reminded_at: los listeners reclaman la factura de forma atómica escribiendo esa columna antes de encolar el email. El que pierde la carrera no encuentra la fila elegible y no envía nada.
Encima de eso hay un segundo freno, a nivel de lote: el parámetro de sistema invoice_overdue_invoked. doBatchInvokeDueEvent() con once_per_day = true no vuelve a lanzar los eventos si han pasado menos de 86400 segundos desde la última vez.
flowchart TD
CRON["cron.php"] --> A["invoice_batch_send_reminders"]
CRON --> B["invoice_batch_invoke_due_event"]
B --> C{"invoice_overdue_invoked hace menos de 86400 s"}
C -- Si --> D["No se lanza nada hoy"]
C -- No --> E["fireDueReminderEvents"]
E --> F["onEventBeforeInvoiceIsDue"]
E --> G["onEventAfterInvoiceIsDue"]
F --> H{"Coincide con un intervalo configurado"}
G --> H
H -- No --> I["Sin email"]
H -- Si --> J{"Reclamar factura escribiendo reminded_at"}
J -- Perdio la carrera --> I
J -- Gano --> K["Encolar email y disparar onAfterAdminInvoiceReminderSent"]
Consecuencia práctica: si estás depurando recordatorios y quieres reintentar hoy mismo, tienes que tocar invoice_overdue_invoked en la tabla setting, o el lote te ignorará durante 24 horas:
SELECT param, value FROM setting WHERE param = 'invoice_overdue_invoked';
Para un envío puntual y controlado existe invoice/send_reminder, que no pasa por el throttling del lote. Y recuerda que el email sale por la cola: si queue_once del módulo Email vale 0, Email\Service::batchSend() no envía ninguno. Ese parámetro se cubre en el capítulo 9.
Las cuatro plantillas del ciclo de vida de una factura son mod_invoice_created, mod_invoice_payment_reminder, mod_invoice_due_after y mod_invoice_paid.
El orden exacto en el cron
Cron\Service::runCrons() ejecuta las tareas de facturación en este orden, y el orden importa:
$this->_exec($api, 'hook_batch_connect');
$this->di['events_manager']->fire(['event' => 'onBeforeAdminCronRun']);
$this->_exec($api, 'invoice_batch_pay_with_credits');
$this->_exec($api, 'invoice_batch_activate_paid');
// tareas aisladas: un fallo no aborta el resto
'invoice_batch_generate',
'invoice_batch_send_reminders',
'invoice_batch_invoke_due_event',
'order_batch_suspend_expired',
'order_batch_cancel_suspended',
// ...
'email_batch_sendmail',
// después
setParamValue('last_cron_exec', now);
clearOldSessions();
fire('onAfterAdminCronRun'); // el módulo Invoice engancha aquí sus recordatorios
Los créditos se aplican antes de activar lo pagado: así una factura que el saldo cubre se paga y se activa en la misma pasada del cron, sin esperar a la siguiente.
Marcar como pagada a mano y la validación del importe recibido
Cuando el pago llega por transferencia, por efectivo o por cualquier vía que FOSSBilling no ve, el staff usa Invoicing → Invoices → abrir la factura → Mark as paid, o directamente el endpoint:
curl -u 'admin:TU_API_TOKEN' \
-H 'Content-Type: application/json' \
-d '{"id": 148, "gateway_id": 1, "transactionId": "TRANSF-2026-0812", "execute": true}' \
https://billing.example.com/api/admin/invoice/mark_as_paid
Parámetros: gateway_id es obligatorio, transactionId y execute son opcionales. Si la pasarela indicada es la Custom y está habilitada, además de marcar la factura se crea una transacción que va de received a processed con la nota «{gateway} transaction No: {txn}». Para la pasarela Custom el identificador de transacción es obligatorio en el flujo del panel.
La validación de importe vive en validatePaymentAmount() y tiene dos umbrales asimétricos:
| Caso | Umbral | Resultado |
|---|---|---|
| Recibido menor que esperado | diferencia mayor de 0,01 | Excepción: Payment amount does not match the expected invoice total. Expected :expected, received :received. |
| Recibido mayor que esperado | diferencia mayor de 1,00 | Aviso de posible pago aplicado a la factura equivocada |
La tolerancia de un céntimo por abajo absorbe los redondeos de conversión de moneda. El aviso por sobrepago está pensado para el error humano más frecuente en cobranza manual: pegar el importe de otra factura del mismo cliente.
Al marcar como pagada se encadenan varias cosas: serie pasa a invoice_series_paid, status a paid, paid_at a ahora, currency_rate queda congelado, countIncome() rellena base_income convirtiendo a la moneda base, commitReservedPromoRedemptionsForInvoice() consolida las redenciones de cupón que estaban en reserved (ver capítulo 5) y se dispara onAfterAdminInvoicePaymentReceived, que envía mod_invoice_paid.
Pagar con créditos: automático, por lotes y el veto de las líneas deposit
El saldo del cliente es la suma de las filas de client_balance:
SELECT SUM(amount) FROM client_balance WHERE client_id = ? GROUP BY client_id;
Cada movimiento es una fila con client_id, type (default gift), rel_id, description, amount y currency. El detalle del modelo está en el capítulo 6.
Hay tres caminos por los que el saldo acaba pagando una factura. Uno, en el checkout: Cart\Service::createFromCart() sólo pasa use_credits = true si el saldo cubre el total completo ($balanceAmount >= $ca['total']), porque no hay pagos parciales con saldo en el carrito. Dos, al aprobar: Invoice\Service::approveInvoice($invoice, ['use_credits' => true]) llama internamente a tryPayWithCredits(). Tres, por lote: el cron ejecuta invoice_batch_pay_with_credits en cada pasada, antes de invoice_batch_activate_paid, y es lo que hace que una recarga de saldo de ayer pague sola la factura de renovación de hoy.
Endpoints manuales: invoice/pay_with_credits para una factura concreta e invoice/batch_pay_with_credits para forzar el lote.
El veto: las líneas de tipo Model_InvoiceItem::TYPE_DEPOSIT están explícitamente excluidas del pago con créditos, con un comentario dedicado en el modelo. La razón es evitar el bucle absurdo de recargar saldo pagando con saldo: una factura de fondos por 100 no puede liquidarse con 100 de saldo existente, porque el resultado neto sería cero y el cliente vería un movimiento fantasma. El adaptador Payment_Adapter_ClientBalance refuerza lo mismo desde el otro lado, rechazando facturas de depósito con los códigos de error 302 y 303, y exigiendo que la pasarela esté habilitada (código 301).
Facturas de fondos: recargar saldo con límites mínimo y máximo
Invoice\Service::generateFundsInvoice() crea una factura con una única línea de tipo deposit. El cliente la paga por la pasarela que sea y, al acreditarse, el importe entra en client_balance.
Dos parámetros la acotan, ambos en el bloque Add Funds Settings de Invoice Settings:
| Parámetro | Semilla | Vacío significa |
|---|---|---|
funds_min_amount | 10 | sin mínimo |
funds_max_amount | 200 | sin máximo |
El área de cliente consulta si la función está disponible con el endpoint de invitado invoice/funds_enabled; si no aparece la opción de recargar saldo, ese endpoint es lo primero que hay que probar. El staff puede además mover saldo directamente con client/balance_add_funds (requiere id, amount y description; permiso client:manage_balance) sin generar factura, que es lo que se usa para compensaciones y ajustes. Ojo con su error más común: You must define the client's currency before adding funds. significa exactamente eso, que el cliente no tiene moneda asignada.
Transacciones: los cinco estados internos y los que reporta la pasarela
Cambiamos de tabla. Cada notificación de pago que entra por src/ipn.php crea una fila en transaction. Los estados internos están en src/library/Model/Transaction.php:
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';
Las etiquetas que ves en el panel las devuelve ServiceTransaction::getStatuses(): Received / Approved-Verified / Processing / Processed / Error.
Hay un segundo juego de estados que no hay que confundir con los anteriores: los que reporta la pasarela, en Payment_Transaction, y que se guardan en la columna transaction.txn_status:
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';
Una transacción puede estar processed internamente y failed según la pasarela: significa que FOSSBilling procesó correctamente la notificación de un pago fallido. No es una contradicción.
stateDiagram-v2
[*] --> received: ipn.php crea el registro
received --> processing: claimForProcessing UPDATE atomico
processing --> processed: processTransaction del adaptador OK
processing --> error: excepcion capturada por markTransactionError
error --> processing: reintento desde el boton Process o un IPN repetido
processing --> processing: reclaim tras PROCESSING_RECOVERY_TIMEOUT
processed --> [*]
note right of error
columnas error y error_code
log: Failed to process transaction
end note
Las tres capas de deduplicación de un IPN
Las pasarelas reenvían. Stripe reintenta ante un timeout, PayPal reenvía el IPN hasta obtener acuse. Si cada reenvío acreditara un pago, el saldo del cliente crecería solo. ServiceTransaction::create() tiene tres defensas superpuestas.
Capa 1: por txn_id más gateway_id. Si ya existe una transacción processed con ese identificador externo para esa pasarela, devuelve el id existente y no crea nada. Los candidatos a txn_id se buscan en este orden exacto:
$data['txn_id']
?? $data['post']['txn_id'] ?? $data['get']['txn_id']
?? $data['post']['payment_intent'] ?? $data['get']['payment_intent']
El fallback a payment_intent es lo que cubre a Stripe, que no manda un campo llamado txn_id.
Capa 2: por hash canónico del payload. Se calcula un hash sobre la estructura completa {source, get, post, http_raw_post_data, server}, ordenando las claves recursivamente con recursiveKsort() para que dos payloads idénticos con distinto orden de claves produzcan el mismo hash. Se busca por el par (gateway_id, ipn_hash).
Advertencia: esta capa requiere que existan la columna transaction.ipn_hash y el índice transaction_ipn_hash_idx. supportsTransactionIpnHash() los comprueba y, si faltan, desactiva la deduplicación por hash silenciosamente, dejando sólo un warning en el log. Es un escenario real en instalaciones migradas cuyo esquema no se actualizó del todo. Comprobación:
SHOW COLUMNS FROM transaction LIKE 'ipn_hash';
SHOW INDEX FROM transaction WHERE Key_name = 'transaction_ipn_hash_idx';
Capa 3: por adaptador. Cada pasarela puede añadir su propia lógica. Payment_Adapter_PayPalEmail::isIpnDuplicate() 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
Las tres capas son complementarias, no redundantes: la primera sólo actúa si hay identificador externo, la segunda cubre payloads sin identificador, y la tercera aporta el criterio de negocio específico de cada proveedor. El detalle de cómo cada adaptador verifica la autenticidad del callback se ve en el capítulo 10.
claimForProcessing(): el UPDATE atómico contra las condiciones de carrera
Dos procesos pueden intentar procesar la misma transacción a la vez: el propio ipn.php de un reenvío y el botón Process que un administrador acaba de pulsar. La solución es no usar un SELECT seguido de un UPDATE, sino un único UPDATE condicional cuyo número de filas afectadas decide quién gana:
$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;
Lo que acepta la reclamación: received, el caso normal de una transacción recién creada; error, que permite reintentar desde el botón del panel o desde un IPN repetido de PayPal; y processing obsoleta, cuando updated_at es anterior al umbral PROCESSING_RECOVERY_TIMEOUT, lo que rescata las transacciones colgadas porque el proceso murió a mitad.
Lo que no acepta: processed. Una transacción ya procesada no se vuelve a reclamar nunca, y ese es el último cortafuegos contra el doble cobro.
Este SQL tuvo un bug de agrupación de la cláusula IN, corregido en 0.8.3 (PR #3782, “SQL claim queries use correct IN grouping”). Si estás en una versión anterior a 0.8.3, actualiza antes de depurar nada de esto.
La contraparte es markTransactionError(), que recarga la transacción desde base de datos antes de escribir el error:
$tx = $this->di['db']->load('Transaction', $id);
if (!$tx instanceof \Model_Transaction || $tx->status === \Model_Transaction::STATUS_PROCESSED) {
return;
}
$tx->status = STATUS_ERROR;
$tx->error = $e->getMessage();
$tx->error_code = $e->getCode();
$this->di['db']->store($tx);
$this->di['logger']->error('Failed to process transaction #%s: %s', $id, $e->getMessage());
La condición === STATUS_PROCESSED es la clave: una excepción tardía no puede pisar un cobro exitoso. Si el pago se acreditó y después algo falló al enviar un email o al activar un servicio, la transacción se queda en processed y el error va sólo al log.
También hay un endpoint expuesto para la reclamación: invoice/transaction_claim_for_processing.
Reprocesar transacciones y leer el payload crudo guardado
Cuando una transacción se quedó atascada, hay tres formas de empujarla.
Desde la UI: Invoicing → Transactions → botón Process en la fila.
Por API, una:
curl -u 'admin:TU_API_TOKEN' \
-H 'Content-Type: application/json' \
-d '{"id": 331}' \
https://billing.example.com/api/admin/invoice/transaction_process
Permiso necesario: invoice:manage_transactions.
Por API, todas las pendientes: invoice/transaction_process_all llama a processReceivedATransactions(), 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
Es decir: recoge tanto las received que nunca se tocaron como las processing caducadas. Este método no está en la lista de tareas del cron, es una acción manual.
Para entender por qué falló una transacción, la fuente de verdad es la columna transaction.ipn, que guarda el JSON completo del webhook tal como llegó:
{
"source": "ipn",
"get": { "gateway_id": "3", "invoice_id": "148" },
"post": { },
"http_raw_post_data": "{\"id\":\"evt_...\",\"type\":\"payment_intent.succeeded\"}",
"server": { "HTTP_STRIPE_SIGNATURE": "t=1754..." }
}
Con eso puedes reproducir el webhook exacto contra tu entorno de pruebas. Consultas útiles:
# Panorama de las últimas 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 una concreta
mysql -e "SELECT ipn FROM transaction WHERE id=331\G" fossbilling
Los eventos que rodean el procesado son onBeforeAdminTransactionProcess y onAfterAdminTransactionProcess.
Reembolsos: los tres modos de invoice_refund_logic
El endpoint es invoice/refund y detrás está Invoice\Service::refundInvoice(). Lo que hace depende del parámetro invoice_refund_logic, un radio de tres valores en el bloque Refunds Settings de Invoice Settings. La semilla de instalación es credit_note.
| Modo | Documento generado | Serie y numeración |
|---|---|---|
negative_invoice | Factura nueva con status = refunded, approved = 1, vendedor y comprador copiados y las líneas duplicadas con price en negativo | serie = invoice_series_paid, continúa el contador global invoice_starting_number |
credit_note | Lo mismo, pero es una nota de crédito | serie = invoice_cn_series (default CN-), nr = invoice_cn_starting_number, contador independiente |
manual | Nada. Sólo un warning si debug está activo | No aplica |
La precondición es dura: getTotalWithTax($invoice) > 0. Si no se cumple, la llamada lanza Cannot refund invoice with negative amount. No puedes reembolsar una factura ya negativa, lo que impide encadenar reembolsos de reembolsos.
El rastro documental queda en las notas de ambas facturas:
- En la original:
Refund invoice #{id} generated. - En la nueva:
Refund for #{id} invoice.
Eventos: onBeforeAdminInvoiceRefund y onAfterAdminInvoiceRefund.
Gotcha: el modo manual no es “hacer el reembolso a mano en el panel”. Es no generar ningún documento. Si tu contabilidad exige un documento de abono por cada devolución y tienes manual configurado, estás perdiendo el registro. Sólo tiene sentido si llevas los abonos en un sistema externo.
Y el detalle que se olvida siempre: refundInvoice() genera el documento contable, no mueve dinero. La devolución real la haces en el panel de Stripe, de PayPal o donde corresponda. FOSSBilling no envía la orden de reembolso a la pasarela desde este endpoint.
Notas de crédito con numeración independiente
El modo credit_note merece sección propia porque introduce el único contador paralelo del sistema. Dos parámetros de sistema lo gobiernan, y ninguno de los dos está expuesto en la página de Invoice Settings:
| Parámetro | Semilla | Función |
|---|---|---|
invoice_cn_series | CN- | Prefijo de las notas de crédito |
invoice_cn_starting_number | 1 | Contador propio, se incrementa tras cada nota |
Al no compartir contador con invoice_starting_number, la secuencia de notas de crédito es continua e independiente: CN-1, CN-2, CN-3, sin huecos causados por las facturas ordinarias emitidas entre medias. Esto es lo que suelen exigir las jurisdicciones que tratan la nota de crédito como una serie fiscal aparte. Para leerlos o cambiarlos, van por el mismo camino que el resto de parámetros de sistema:
SELECT param, value FROM setting
WHERE param IN ('invoice_refund_logic', 'invoice_cn_series', 'invoice_cn_starting_number');
Advertencia: si cambias de credit_note a negative_invoice con notas ya emitidas, las nuevas devoluciones pasan a consumir el contador global y la serie CN- queda congelada donde estaba. No hay migración ni renumeración; decide el modo antes de la primera devolución.
El PDF con Dompdf, el acceso por hash y sus rate limits
El PDF se genera con Dompdf a partir de dos ficheros del propio módulo:
src/modules/Invoice/templates/pdf/
default-invoice.twig
default-invoice.css
fonts/
El tamaño de página lo decide invoice_document_format, un radio con dos valores: Letter o A4. En fonts/ van incluidas fuentes Noto para tailandés y lao, porque Dompdf no resuelve esos scripts con las fuentes por defecto.
Desde 0.8.4 existe invoice_email_attach_pdf: activado, adjunta el PDF al email de la factura en lugar de obligar al cliente a entrar al área de cliente.
El hash y lo que expone
Cada factura tiene un hash único generado con bin2hex(random_bytes(random_int(15, 30))), es decir entre 30 y 60 caracteres hexadecimales. Es lo que hace que la URL de pago funcione sin sesión:
https://billing.example.com/invoice/{hash}
https://billing.example.com/invoice/thank-you/{hash}
El parámetro invoice_accessible_from_hash decide si ese enlace basta para ver la factura completa sin autenticarse. El propio panel lo advierte y conviene repetirlo: con esa opción activa, cualquiera con el enlace ve nombre, email, dirección, teléfono, país y número de VAT del comprador. Es un enlace no adivinable, no un enlace privado: acaba en cadenas de email reenviadas, en historiales de navegador compartidos y en tickets de soporte.
Desde 0.8.2 el hash caduca. invoice_hash_lifetime_days vale 90 por defecto y 0 significa que nunca caduca; la fecha efectiva se guarda en invoice.hash_expires_at. Cada vez que se reenvía el email de la factura, extendInvoiceHashLifetime() empuja esa fecha hacia adelante, así que un cliente que pide “reenvíame la factura” recupera el acceso sin intervención.
Los seis límites de tasa de las rutas públicas
También desde 0.8.2, los endpoints de invitado del módulo Invoice están limitados. Los valores por defecto los define FOSSBilling\Security\RateLimiter::getDefaultConfig():
| Política | Tipo | Límite | Intervalo |
|---|---|---|---|
invoice_get_ip | fixed_window | 10 | 1 hour |
invoice_get_hash | fixed_window | 30 | 1 hour |
invoice_pdf_ip | fixed_window | 10 | 1 hour |
invoice_pdf_hash | fixed_window | 10 | 1 hour |
invoice_payment_ip | fixed_window | 10 | 1 hour |
invoice_payment_hash | fixed_window | 10 | 1 hour |
Fíjate en el patrón: los límites por hash son iguales o más generosos que los límites por IP, porque un hash identifica una factura concreta mientras que una IP puede ser una oficina entera detrás de NAT.
Gotcha heredado del capítulo 3: el limitador resuelve la IP con $this->di['request']->getClientIp(). Sin trusted_proxies configurado, todas las peticiones detrás de tu reverse proxy comparten la IP del proxy y diez descargas de PDF de diez clientes distintos agotan el cupo para todos. Se sobrescribe en config.php:
'rate_limiter' => [
'enabled' => true,
'whitelist_ips' => [],
'policies' => [
'invoice_pdf_ip' => ['policy' => 'fixed_window', 'limit' => 30, 'interval' => '1 hour'],
],
],
Los cinco endpoints de invitado del módulo son invoice/get, invoice/gateways, invoice/payment, invoice/funds_enabled e invoice/pdf.
Diagnóstico de cobranza: el pago que no acredita y la factura que no llega
La pregunta operativa siempre es la misma y siempre se resuelve igual: primero mira si existe la fila en transaction. Todo lo demás depende de esa respuesta.
flowchart TD
A["El cliente dice que pago y la factura sigue unpaid"] --> B{"Existe fila en la tabla transaction"}
B -- No --> C["El webhook nunca llego a FOSSBilling"]
C --> C1["Revisar los intentos y codigos HTTP en el panel del proveedor"]
C --> C2["curl -i -X POST al endpoint ipn.php desde fuera"]
C --> C3["Revisar WAF o Cloudflare bloqueando el POST y las reescrituras del webserver"]
B -- Si --> D{"Valor de la columna status"}
D -- received --> E["Nunca se proceso: lanzar transaction_process"]
D -- processing --> F["Colgada: esperar el recovery timeout o reclamarla de nuevo"]
D -- error --> G["Leer las columnas error y error_code"]
D -- processed --> H["Se proceso: mirar si la factura quedo paid y con que gateway_id"]
G --> G1["4001 significa pasarela mal configurada"]
G --> G2["Invalid Stripe webhook signature"]
G --> G3["IPN is invalid en PayPal"]
G --> G4["701 significa gateway_id nulo en la transaccion"]
Comandos de la primera pasada:
# ¿Responde el endpoint desde fuera? Debe devolver JSON, no 404 ni 403
curl -i -X POST "https://billing.example.com/ipn.php?gateway_id=1&invoice_id=1"
# Últimas transacciones con su error
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
# Logs relevantes
tail -f data/log/application/application-$(date +%Y-%m-%d).log
tail -f data/log/billing/billing-$(date +%Y-%m-%d).log
tail -f data/log/email/email-$(date +%Y-%m-%d).log
Y una nota sobre el rendimiento del webhook que se paga cara: ipn.php sólo responde rápido y procesa en segundo plano si fastcgi_finish_request() existe, es decir bajo PHP-FPM. Con mod_php o el servidor embebido, el procesado es síncrono y Stripe puede reintentar por timeout, generando transacciones extra que las tres capas de deduplicación tienen que absorber. Es un argumento operativo directo a favor del despliegue con PHP-FPM que montaste en el capítulo 2.
Referencia rápida de la API del módulo Invoice
Todo lo anterior está expuesto en src/modules/Invoice/Api/Admin.php:
invoice/get_list invoice/get invoice/prepare
invoice/approve invoice/update invoice/delete
invoice/mark_as_paid invoice/refund invoice/item_delete
invoice/renewal_invoice invoice/pay_with_credits invoice/batch_pay_with_credits
invoice/batch_generate invoice/batch_activate_paid invoice/batch_send_reminders
invoice/batch_invoke_due_event invoice/send_reminder invoice/get_statuses
invoice/batch_delete invoice/export_csv
Y los siete permisos del módulo, que se asignan por grupo desde 0.8.4:
| Clave | Cubre |
|---|---|
view | Ver facturas y listados |
manage_invoices | Crear, aprobar, editar, marcar pagada, reembolsar, borrar |
manage_transactions | Procesar y reprocesar transacciones |
manage_gateways | Instalar y configurar pasarelas |
manage_subscriptions | Suscripciones de pasarela |
manage_tax | Reglas de impuestos |
export | Exportación CSV |
Separar manage_invoices de manage_transactions tiene sentido operativo: el equipo de facturación emite y cobra, pero reprocesar un webhook es una acción técnica con consecuencias de dinero.
Errores comunes y diagnóstico
| Síntoma | Causa probable | Diagnóstico | Solución |
|---|---|---|---|
| No se generan facturas de renovación | El pedido incumple alguna de las cuatro condiciones del SQL | SELECT status, invoice_option, period, expires_at, unpaid_invoice_id FROM client_order WHERE id = N | Poner invoice_option = 'issue-invoice', verificar period y expires_at, y que no haya ya una factura unpaid asociada |
| No llegan recordatorios de ningún tipo | Facturas con approved = 0 | SELECT id,status,approved,due_at,reminded_at FROM invoice WHERE status='unpaid' | Activar invoice_auto_approval o aprobar las facturas con invoice/approve |
| Los recordatorios no se repiten hoy | Throttling diario ya consumido | SELECT value FROM setting WHERE param='invoice_overdue_invoked' | Esperar a que pasen 86400 s o usar invoice/send_reminder para el envío puntual |
| El email de recordatorio se genera pero no sale | queue_once = 0 en la config del módulo Email | Revisar la tabla mod_email_queue | Poner queue_once mayor que 0 |
| El servicio se suspende con la factura en plazo | due_at de la factura es posterior a expires_at del pedido | Comparar ambas columnas cruzando invoice_item.rel_id | Bajar invoice_due_days o subir invoice_issue_days_before_expire |
| Facturas con un solo día de plazo | invoice_due_days vacío o no numérico: el fallback en código es 1 | SELECT value FROM setting WHERE param='invoice_due_days' | Reponer un valor numérico |
El pago no acredita y no hay fila en transaction | El webhook nunca llegó | curl -i -X POST al endpoint y revisar los intentos en el panel del proveedor | Corregir la URL de callback, las reescrituras del webserver o la regla del WAF |
Transacción en received que no avanza | Nunca se procesó | Panel Invoicing → Transactions | invoice/transaction_process con su id |
Transacción atascada en processing | El proceso murió a mitad | Comparar updated_at con el umbral de recuperación | Esperar a PROCESSING_RECOVERY_TIMEOUT y volver a reclamarla |
Transacción en error | Excepción del adaptador | Leer error y error_code; 4001 es configuración de pasarela | Corregir la configuración y reprocesar |
| Código de error 701 en la transacción | transaction.gateway_id es NULL | SELECT gateway_id FROM transaction WHERE id = N | Suele venir de un webhook JSON con skip_validation; fijar el gateway_id o recrear la pasarela |
| Pagos duplicados tras un reenvío de webhook | Falta la columna ipn_hash o su índice | SHOW COLUMNS FROM transaction LIKE 'ipn_hash' | Actualizar el esquema; mientras tanto la dedupe por hash está desactivada con un warning |
| Reclamaciones de transacción que fallan siempre | Bug de agrupación IN anterior a 0.8.3 | Comprobar la versión con php console.php system:version | Actualizar a 0.8.3 o superior |
Payment amount does not match the expected invoice total | Importe recibido por debajo de la tolerancia de 0,01 | Comparar el total de la factura con el importe introducido | Ajustar el importe o emitir una factura complementaria |
| El reembolso no genera ningún documento | invoice_refund_logic = 'manual' | SELECT value FROM setting WHERE param='invoice_refund_logic' | Cambiar a credit_note o negative_invoice |
Cannot refund invoice with negative amount | La factura ya es negativa o su total con impuestos no es mayor que 0 | Revisar las líneas de la factura | Reembolsar la factura original, no el documento de abono |
| El dinero no vuelve al cliente tras el reembolso | refundInvoice() sólo genera el documento contable | Revisar el panel de la pasarela | Ejecutar la devolución en Stripe o PayPal manualmente |
| La serie de las facturas pagadas no cambia | invoice_series_paid está vacío | Invoice Settings → Paid Invoice Prefix | Definir la serie antes de emitir |
| Todos los clientes reciben 429 al descargar el PDF | El limitador ve una sola IP por falta de trusted_proxies | tail data/log/security/security-$(date +%F).log | Configurar trusted_proxies, no subir los límites primero |
| El enlace de la factura deja de funcionar a los tres meses | invoice_hash_lifetime_days = 90 | SELECT hash_expires_at FROM invoice WHERE id = N | Reenviar el email de la factura, que extiende el hash, o poner el parámetro a 0 |
| Los datos de empresa o el IVA antiguos siguen en las facturas | Se congelan en setInvoiceDefaults() | Comparar seller_*, taxname y taxrate con la configuración actual | Regenerar las facturas afectadas |
| Las facturas impagadas desaparecen solas | remove_after_days distinto de 0 | Invoice Settings → Remove Unpaid Invoices After | Ponerlo a 0 si quieres conservarlas siempre |
Lo que queda operativo
Tienes el motor de cobranza completo: sabes que la proforma y la factura son la misma fila y qué tres columnas cambian al pagar, qué congela setInvoiceDefaults() y por qué eso obliga a cerrar empresa e impuestos antes de emitir, y por qué approved = 0 desactiva silenciosamente todos los recordatorios. Tienes las cuatro condiciones del SQL de renovación como primer punto de diagnóstico, la relación real entre due_at e expires_at, y el doble freno de los recordatorios: el candado reminded_at contra los crons solapados y el throttling de 86400 segundos de invoice_overdue_invoked.
Del lado del dinero entrante tienes el ciclo de la transacción con sus cinco estados, las tres capas de deduplicación con su dependencia oculta de la columna ipn_hash, el UPDATE atómico de claimForProcessing() y la razón por la que markTransactionError() nunca pisa un processed. Y del lado del dinero saliente, los tres modos de invoice_refund_logic con la advertencia de que ninguno mueve dinero de verdad, más la serie independiente de las notas de crédito.
Lo que queda fuera es lo que va antes de que la transacción exista: cómo se instala y configura cada adaptador, qué URL de callback pegar en el panel del proveedor, cómo Stripe verifica la firma HMAC y por qué en PayPal un VERIFIED no basta. Eso es el capítulo 10. Antes, en el capítulo 9, montas el otro extremo de esta cadena: la cola de correo que hace que los recordatorios que acabas de configurar lleguen de verdad, los departamentos de soporte y el modelo de permisos por grupos que decide quién puede tocar cada uno de los siete permisos del módulo Invoice.