WooCommerce: del pedido pagado a la factura emitida
Un gancho, seis llamadas y una cabecera que impide el duplicado. En PHP, que es donde vive WooCommerce, y con las tres decisiones españolas que la tienda no trae de fábrica.
La API está en marcha. Esto es lo que hay hoy y lo que no
Funciona. Las rutas de esta documentación están desplegadas y respondiendo en https://erp.cairos.es/api/v1/. Los ejemplos de estas páginas se pueden copiar y ejecutar. La especificación completa está en openapi.json, que es lo que importan Make y n8n.
Las claves te las creas tú, desde Desarrolladores en tu cuenta de Cairos. Empieza con una cai_test_: trabaja contra tus datos de verdad pero no registra en VeriFactu ni envía correos, así que puedes montar tu integración sin ensuciar una serie de facturación.
Y lo que todavía NO hay, dicho sin adornos: ningún conector oficial. Ni app de Shopify, ni plugin de WooCommerce, ni módulo publicado en Make o n8n. Con la API y su especificación se pueden construir —para eso están las guías de esta sección— pero construirlos es trabajo, y ese trabajo no está hecho.
Si montas algo con esto, escríbenos a hola@cairos.es. Nos interesa especialmente lo que te falte del contrato: es lo que decide qué se amplía primero.
No hay plugin oficial de Cairos para WooCommerce
No lo busques en el repositorio de WordPress, porque no está. Lo que hay es la API y esta página, que te dice exactamente qué enganchar y con qué código. Es menos cómodo que instalar algo y es lo que hay hoy; decirlo así te ahorra buscar un conector que no existe.
Lo que hace la integración, en tres movimientos
El orden importa: es lo que evita que un corte de red acabe en dos facturas.
El pedido se paga
WooCommerce dispara woocommerce_payment_complete. Ahí, y no antes, hay algo que facturar.
Se comprueba y se llama
Si el pedido ya tiene factura guardada, no se hace nada. Si no, se llama con Idempotency-Key: wc-1042.
Se guarda antes de seguir
El identificador de la factura se escribe en el pedido antes de emitir. Así un fallo posterior no borra la pista.
Lo primero: decidir cuándo se factura
Es la decisión que más consecuencias tiene y la que casi nadie piensa. WooCommerce mueve un pedido por varios estados, y sólo uno de ellos es el bueno para emitir una factura.
| Gancho de WooCommerce | ¿Facturar aquí? | Por qué |
|---|---|---|
woocommerce_new_order | No | El pedido existe pero puede no llegar a pagarse nunca. Facturarías carritos abandonados con pasarela abierta. |
woocommerce_payment_complete | Sí | El dinero está cobrado. Es el momento natural: hay hecho imponible y hay cobro que registrar. |
woocommerce_order_status_completed | También vale | Si vendes producto físico y prefieres facturar al enviar. Escoge uno de los dos, no los dos. |
Si enganchas los dos, un pedido pasará por ambos y llamarás dos veces. Con Idempotency-Key derivada del pedido no pasa nada; sin ella, tienes dos facturas. Ésa es toda la diferencia.
El NIF, que WooCommerce no te pide
Una tienda de WooCommerce recién instalada pregunta nombre, dirección y correo. No pregunta el NIF, porque el formulario de facturación es el estándar internacional y no sabe nada de España.
Y sin NIF no hay factura completa: hay factura simplificada, que tiene tope de importe y que tu cliente empresa no puede deducirse. Así que antes de escribir una línea de integración hay que añadir ese campo al proceso de compra, con cualquiera de los complementos de facturación española que hay, o a mano con el gancho de campos de facturación de WooCommerce.
Después, en el código de abajo, ese campo es lo que viaja a nif del contacto. Si llega vacío, decide qué haces: crear el contacto sin NIF y emitir simplificada, o parar y avisar. Lo que no hay que hacer es inventarse un NIF de relleno, que es lo que acaba descuadrando el 347 en febrero.
El enganche, entero
Esto va en un plugin propio —un fichero en wp-content/plugins/—, no en el functions.php del tema: el día que cambies de tema no quieres perder la facturación.
<?php
/**
* Plugin Name: Cairos para WooCommerce
*/
define("CAIROS_BASE", "https://erp.cairos.es/api/v1");
function cairos_llamar($ruta, $cuerpo = null, $llave = null) {
$cabeceras = [
"Authorization" => "Bearer " . getenv("CAIROS_CLAVE"),
"Content-Type" => "application/json",
];
if ($llave) {
$cabeceras["Idempotency-Key"] = $llave;
}
$r = wp_remote_post(CAIROS_BASE . $ruta, [
"headers" => $cabeceras,
"body" => $cuerpo ? wp_json_encode($cuerpo) : null,
"timeout" => 20,
]);
if (is_wp_error($r)) {
throw new Exception($r->get_error_message());
}
$estado = wp_remote_retrieve_response_code($r);
$datos = json_decode(wp_remote_retrieve_body($r), true);
if ($estado >= 400) {
$codigo = $datos["error"]["codigo"] ?? "desconocido";
throw new Exception("Cairos " . $estado . ": " . $codigo);
}
return $datos;
}
add_action("woocommerce_payment_complete", "cairos_facturar_pedido");
function cairos_facturar_pedido($id_pedido) {
$pedido = wc_get_order($id_pedido);
// 1 · Si ya lo facturamos, no hay nada que hacer. Esta línea
// vale más que todo el resto del fichero.
if ($pedido->get_meta("_cairos_factura_id")) {
return;
}
$llave = "wc-" . $id_pedido;
try {
// 2 · Contacto. El NIF sale del campo que añadiste al
// proceso de compra.
$contacto = cairos_llamar("/contactos", [
"nombre" => trim($pedido->get_billing_company()
?: $pedido->get_formatted_billing_full_name()),
"nif" => $pedido->get_meta("_billing_nif"),
"email" => $pedido->get_billing_email(),
"tipo" => "cliente",
], $llave . "-contacto");
// 3 · Líneas. get_subtotal() es SIN impuestos, que es lo
// que espera la API. Si tu tienda enseña precios con
// IVA incluido, sigue siendo el campo correcto.
$lineas = [];
foreach ($pedido->get_items() as $item) {
$cantidad = $item->get_quantity();
$lineas[] = [
"concepto" => $item->get_name(),
"cantidad" => $cantidad,
"precio" => round($item->get_subtotal() / max(1, $cantidad), 2),
"iva" => 21,
];
}
// 4 · Borrador.
$factura = cairos_llamar("/facturas", [
"contacto_id" => $contacto["id"],
"serie" => "WEB",
"fecha" => $pedido->get_date_paid()->date("Y-m-d"),
"lineas" => $lineas,
], $llave);
// 5 · Guardamos ANTES de emitir. Si algo falla después,
// al menos sabemos que este pedido ya tiene factura.
$pedido->update_meta_data("_cairos_factura_id", $factura["id"]);
$pedido->save();
// 6 · Emitir: aquí es donde toma número de serie.
$emitida = cairos_llamar(
"/facturas/" . $factura["id"] . "/emitir",
null,
$llave . "-emitir"
);
// 7 · Y el cobro, que en una tienda ya ha ocurrido.
cairos_llamar("/cobros", [
"factura_id" => $factura["id"],
"fecha" => $pedido->get_date_paid()->date("Y-m-d"),
"importe" => (float) $pedido->get_total(),
"medio" => $pedido->get_payment_method_title(),
], $llave . "-cobro");
$pedido->update_meta_data("_cairos_factura_numero", $emitida["numero"]);
$pedido->add_order_note("Factura " . $emitida["numero"]);
$pedido->save();
} catch (Exception $e) {
// Ni una excepción sin registrar: un fallo silencioso aquí
// se descubre en el cierre del trimestre.
$pedido->add_order_note("Cairos: " . $e->getMessage());
error_log("Cairos: " . $e->getMessage());
}
}Los cuatro detalles que no se ven en el código
- La clave, en una variable de entorno. En el código de arriba se lee con
getenv. Si la pones en el fichero del plugin, viaja a tu repositorio y a cada copia de seguridad. Y desde luego no va en la base de datos de WordPress con un campo de opciones visible. - El tipo de IVA, de la línea y no fijo. El ejemplo pone
21para que se lea. Si vendes libros, alimentación o servicios con tipos distintos, saca el tipo de los impuestos de cada línea del pedido; poner 21 en todo es un error contable, no de programación. - Los gastos de envío también facturan. No están en
get_items(): hay que añadirlos como una línea más, con su tipo de IVA, que en España es el del bien principal. - Las devoluciones no se resuelven aquí. Un reembolso en WooCommerce no borra una factura emitida: hace falta una rectificativa. Hoy eso se hace desde Cairos, a mano. Es honesto decirlo antes de que alguien monte la tienda contando con que se arregla solo.
Cómo probarlo sin ensuciar tu facturación
Con la clave cai_test_, que trabaja sobre tus mismos datos pero no registra en VeriFactu ni manda correos. Y con una serie de facturación aparte —PRUEBAS, por ejemplo— para que los números de tus pruebas no se mezclen con los de tu serie de verdad.
Después, un pedido real de un euro con tu propia tarjeta y comprobar tres cosas: que aparece la factura, que el número está guardado en el pedido, y que repetir el gancho no crea una segunda. Esa tercera es la que hay que probar de verdad, y se prueba llamando dos veces a la función a mano.
Preguntas sobre WooCommerce
woocommerce_payment_complete, o al completarlo si vendes producto físico. Nunca al crear el pedido: un carrito con la pasarela abierta puede no pagarse nunca.Idempotency-Key: wc-1042. La primera evita el reproceso; la segunda, el corte de red. Está explicado en idempotencia.Cuando la API abra, este código funciona tal cual
Está escrito contra el contrato definitivo. Móntalo ahora, pruébalo el día que te demos la clave y no habrá que rehacer nada.
PHP sin dependencias · Idempotency-Key · Serie de pruebas aparte