Facturación, pagos y cobranza: facturas, transacciones, créditos y reembolsos

Por: Artiko
fossbillingfacturascobranzatransaccionescreditosreembolsosnotas-de-creditoipnrecordatoriospdfdompdf

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:

Momentostatusapprovedseriepaid_atcurrency_rate
Recién creadaunpaid0valor de invoice_series (semilla FOSS)NULLtasa vigente
Aprobadaunpaid1igualNULLigual
Pagadapaid1valor de invoice_series_paidnowcongelado

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:

GrupoColumnas
Identidad del documentoserie, nr, hash (UNIQUE), hash_expires_at
Dinerocurrency, currency_rate, credit, base_income, base_refund, refund
Textonotes, text_1, text_2
Estado y ciclostatus, approved, due_at, reminded_at, paid_at
Vendedor congeladoseller_company, seller_company_vat, seller_company_number, seller_address, seller_phone, seller_email
Comprador congeladobuyer_first_name, buyer_last_name, buyer_company, buyer_company_vat, buyer_address, buyer_city, buyer_state, buyer_country, buyer_zip, buyer_phone, buyer_email
Cobrogateway_id
Impuesto congeladotaxname, 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 columnas seller_*.
  • 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).
  • serie y nr: el prefijo de invoice_series y el siguiente número del contador.
  • hash: bin2hex(random_bytes(random_int(15, 30))), es decir entre 30 y 60 caracteres hexadecimales.
  • taxname y taxrate: la tasa resuelta para ese cliente en ese momento.
  • notes: el contenido del parámetro invoice_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 onEventBeforeInvoiceIsDue ni onEventAfterInvoiceIsDue;
  • por tanto no genera ningún email de recordatorio, aunque invoice_reminder_after_due_days esté 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.

EjeValoresPara qué sirve
typedeposit, custom, order, hook_callQué representa la línea
taskvoid, activate, renewQué hay que ejecutar cuando la factura se pague
statuspending_payment, pending_setup, executedEn 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 > 0 explícitamente, se usa ese valor;
  • si no, y el pedido tiene expires_at, entonces due_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ámetroEtiqueta UISemillaPlantilla de email
invoice_reminder_before_due_daysPayment Reminder Days Before Duevacío = desactivadomod_invoice_payment_reminder
invoice_reminder_after_due_daysPayment Reminder Days After Due5mod_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:

CasoUmbralResultado
Recibido menor que esperadodiferencia mayor de 0,01Excepción: Payment amount does not match the expected invoice total. Expected :expected, received :received.
Recibido mayor que esperadodiferencia mayor de 1,00Aviso 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ámetroSemillaVacío significa
funds_min_amount10sin mínimo
funds_max_amount200sin 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.

ModoDocumento generadoSerie y numeración
negative_invoiceFactura nueva con status = refunded, approved = 1, vendedor y comprador copiados y las líneas duplicadas con price en negativoserie = invoice_series_paid, continúa el contador global invoice_starting_number
credit_noteLo mismo, pero es una nota de créditoserie = invoice_cn_series (default CN-), nr = invoice_cn_starting_number, contador independiente
manualNada. Sólo un warning si debug está activoNo 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ámetroSemillaFunción
invoice_cn_seriesCN-Prefijo de las notas de crédito
invoice_cn_starting_number1Contador 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íticaTipoLímiteIntervalo
invoice_get_ipfixed_window101 hour
invoice_get_hashfixed_window301 hour
invoice_pdf_ipfixed_window101 hour
invoice_pdf_hashfixed_window101 hour
invoice_payment_ipfixed_window101 hour
invoice_payment_hashfixed_window101 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:

ClaveCubre
viewVer facturas y listados
manage_invoicesCrear, aprobar, editar, marcar pagada, reembolsar, borrar
manage_transactionsProcesar y reprocesar transacciones
manage_gatewaysInstalar y configurar pasarelas
manage_subscriptionsSuscripciones de pasarela
manage_taxReglas de impuestos
exportExportació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íntomaCausa probableDiagnósticoSolución
No se generan facturas de renovaciónEl pedido incumple alguna de las cuatro condiciones del SQLSELECT status, invoice_option, period, expires_at, unpaid_invoice_id FROM client_order WHERE id = NPoner invoice_option = 'issue-invoice', verificar period y expires_at, y que no haya ya una factura unpaid asociada
No llegan recordatorios de ningún tipoFacturas con approved = 0SELECT 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 hoyThrottling diario ya consumidoSELECT 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 salequeue_once = 0 en la config del módulo EmailRevisar la tabla mod_email_queuePoner queue_once mayor que 0
El servicio se suspende con la factura en plazodue_at de la factura es posterior a expires_at del pedidoComparar ambas columnas cruzando invoice_item.rel_idBajar invoice_due_days o subir invoice_issue_days_before_expire
Facturas con un solo día de plazoinvoice_due_days vacío o no numérico: el fallback en código es 1SELECT value FROM setting WHERE param='invoice_due_days'Reponer un valor numérico
El pago no acredita y no hay fila en transactionEl webhook nunca llegócurl -i -X POST al endpoint y revisar los intentos en el panel del proveedorCorregir la URL de callback, las reescrituras del webserver o la regla del WAF
Transacción en received que no avanzaNunca se procesóPanel Invoicing → Transactionsinvoice/transaction_process con su id
Transacción atascada en processingEl proceso murió a mitadComparar updated_at con el umbral de recuperaciónEsperar a PROCESSING_RECOVERY_TIMEOUT y volver a reclamarla
Transacción en errorExcepción del adaptadorLeer error y error_code; 4001 es configuración de pasarelaCorregir la configuración y reprocesar
Código de error 701 en la transaccióntransaction.gateway_id es NULLSELECT gateway_id FROM transaction WHERE id = NSuele venir de un webhook JSON con skip_validation; fijar el gateway_id o recrear la pasarela
Pagos duplicados tras un reenvío de webhookFalta la columna ipn_hash o su índiceSHOW 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 siempreBug de agrupación IN anterior a 0.8.3Comprobar la versión con php console.php system:versionActualizar a 0.8.3 o superior
Payment amount does not match the expected invoice totalImporte recibido por debajo de la tolerancia de 0,01Comparar el total de la factura con el importe introducidoAjustar el importe o emitir una factura complementaria
El reembolso no genera ningún documentoinvoice_refund_logic = 'manual'SELECT value FROM setting WHERE param='invoice_refund_logic'Cambiar a credit_note o negative_invoice
Cannot refund invoice with negative amountLa factura ya es negativa o su total con impuestos no es mayor que 0Revisar las líneas de la facturaReembolsar la factura original, no el documento de abono
El dinero no vuelve al cliente tras el reembolsorefundInvoice() sólo genera el documento contableRevisar el panel de la pasarelaEjecutar la devolución en Stripe o PayPal manualmente
La serie de las facturas pagadas no cambiainvoice_series_paid está vacíoInvoice Settings → Paid Invoice PrefixDefinir la serie antes de emitir
Todos los clientes reciben 429 al descargar el PDFEl limitador ve una sola IP por falta de trusted_proxiestail data/log/security/security-$(date +%F).logConfigurar trusted_proxies, no subir los límites primero
El enlace de la factura deja de funcionar a los tres mesesinvoice_hash_lifetime_days = 90SELECT hash_expires_at FROM invoice WHERE id = NReenviar 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 facturasSe congelan en setInvoiceDefaults()Comparar seller_*, taxname y taxrate con la configuración actualRegenerar las facturas afectadas
Las facturas impagadas desaparecen solasremove_after_days distinto de 0Invoice Settings → Remove Unpaid Invoices AfterPonerlo 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.