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 info@cairos.es. Nos interesa especialmente lo que te falte del contrato: es lo que decide qué se amplía primero.
Ya no hace falta programar esto
Cairos conecta con WooCommerce desde dentro del programa: enciendes el módulo de tiendas online, pegas las credenciales que te da tu tienda y cada pedido se convierte en factura con tu serie, tu IVA y tu numeración. No hay que instalar ningún plugin en WordPress: la conexión se hace desde Cairos con una clave de la API REST de WooCommerce, que se crea en dos pantallas. Si lo que buscas es cómo funciona contado para quien vende y no para quien programa, es esta página.
Esta guía se queda para quien prefiera montárselo a su manera, tenga un flujo que no encaje, o quiera saber qué hace el conector por dentro antes de fiarse de él.
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/upsert", [
"nombre" => trim($pedido->get_billing_company()
?: $pedido->get_formatted_billing_full_name()),
"nif" => $pedido->get_meta("_billing_nif"),
"email" => $pedido->get_billing_email(),
// "customer" o "supplier": el enumerado esta en
// ingles y un "cliente" devuelve 422.
"tipo" => "customer",
"buscar_por" => "email",
], $llave . "-contacto");
// 3 · Líneas. get_subtotal() es SIN impuestos, que es lo
// que espera la API por defecto. El campo del texto
// es "descripcion" y el del impuesto "tipo_iva":
// "concepto" e "iva" no existen y dan 422.
$lineas = [];
foreach ($pedido->get_items() as $item) {
$cantidad = $item->get_quantity();
$lineas[] = [
"descripcion" => $item->get_name(),
"cantidad" => $cantidad,
"precio" => round($item->get_subtotal() / max(1, $cantidad), 2),
"tipo_iva" => 21,
];
}
// 4 · Borrador. La fecha es "fecha_emision".
$factura = cairos_llamar("/facturas", [
"contacto_id" => $contacto["id"],
"serie" => "WEB",
"fecha_emision" => $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.
// "metodo" es un enumerado cerrado: transfer, cash,
// card, direct_debit u other. El nombre bonito de la
// pasarela va en "notas", que es texto libre.
cairos_llamar("/cobros", [
"factura_id" => $factura["id"],
"fecha" => $pedido->get_date_paid()->date("Y-m-d"),
"importe" => (float) $pedido->get_total(),
"metodo" => "card",
"notas" => $pedido->get_payment_method_title(),
], $llave . "-cobro");
// "referencia" es lo que sale impreso ("WEB-0042").
// "numero" es un entero y por si solo no dice la serie.
$pedido->update_meta_data("_cairos_factura_ref", $emitida["referencia"]);
$pedido->add_order_note("Factura " . $emitida["referencia"]);
$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 cinco 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_iva, de la línea y no fijo. El ejemplo pone21para 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. Y si tu empresa está en Canarias, el tipo que toca es el del IGIC:GET /organizacionte dice en qué territorio estás, enregion_fiscal. - Si tu tienda trabaja con precios con el IVA dentro, no lo desgloses tú. Manda los precios de la etiqueta y añade
"precios_con_iva" => trueal cuerpo de la factura: Cairos saca la base hacia atrás en céntimos enteros, con el mismo motor que usa el conector de tiendas. El desglose escrito a mano se hace casi siempre hacia delante, y ese céntimo de menos se acumula factura a factura hasta que el 303 no cuadra con la caja. - 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.Este código funciona hoy: crea la clave y pruébalo
Está escrito contra los nombres de campo que el servidor acepta de verdad. Con una clave de pruebas y una serie aparte puedes lanzarlo veinte veces sin ensuciar nada.
PHP sin dependencias · Idempotency-Key · Serie de pruebas aparte