WooCommerce: do pedido pagado á factura emitida
Un gancho, seis chamadas e unha cabeceira que impide o duplicado. En PHP, que é onde vive WooCommerce, e coas tres decisións españolas que a tenda non trae de fábrica.
A API está en marcha. Isto é o que hai hoxe e o que non
Funciona. As rutas desta documentación están despregadas e respondendo en https://erp.cairos.es/api/v1/. Os exemplos destas páxinas pódense copiar e executar. A especificación completa está en openapi.json, que é o que importan Make e n8n.
As claves créalas ti, desde Desenvolvedores na túa conta de Cairos. Comeza cunha cai_test_: traballa contra os teus datos de verdade pero non rexistra en VeriFactu nin envía correos, así que podes montar a túa integración sen enlixar unha serie de facturación.
E o que aínda NON hai, dito sen adornos: ningún conector oficial. Nin app de Shopify, nin plugin de WooCommerce, nin módulo publicado en Make ou n8n. Coa API e a súa especificación pódense construír —para iso están as guías desta sección— pero construílos é traballo, e ese traballo non está feito.
Se montas algo con isto, escríbenos a hola@cairos.es. Interésanos especialmente o que che falte do contrato: é o que decide que se amplía primeiro.
Xa non fai falta programar isto
Cairos conecta con WooCommerce desde dentro do programa: acendes o módulo de tendas en liña, pegas as credenciais que che dá a túa tenda e cada pedido convértese en factura coa túa serie, o teu IVE e a túa numeración. Non hai que instalar ningún plugin en WordPress: a conexión faise desde Cairos cunha clave da API REST de WooCommerce, que se crea en dúas pantallas.
Esta guía queda para quen prefira montalo á súa maneira, teña un fluxo que non encaixe, ou queira saber que fai o conector por dentro antes de fiarse del.
O que fai a integración, en tres movementos
A orde importa: é o que evita que un corte de rede acabe en dúas facturas.
O pedido págase
WooCommerce dispara woocommerce_payment_complete. Aí, e non antes, hai algo que facturar.
Compróbase e chámase
Se o pedido xa ten factura gardada, non se fai nada. Se non, chámase con Idempotency-Key: wc-1042.
Gárdase antes de seguir
O identificador da factura escríbese no pedido antes de emitir. Así un fallo posterior non borra a pista.
O primeiro: decidir cando se factura
É a decisión que máis consecuencias ten e a que case ninguén pensa. WooCommerce move un pedido por varios estados, e só un deles é o bo para emitir unha factura.
| Gancho de WooCommerce | Facturar aquí? | Por que |
|---|---|---|
woocommerce_new_order | Non | O pedido existe pero pode non chegar a pagarse nunca. Facturarías carriños abandonados con pasarela aberta. |
woocommerce_payment_complete | Si | O diñeiro está cobrado. É o momento natural: hai feito impoñible e hai cobro que rexistrar. |
woocommerce_order_status_completed | Tamén vale | Se vendes produto físico e prefires facturar ao enviar. Escolle un dos dous, non os dous. |
Se enganchas os dous, un pedido pasará por ambos e chamarás dúas veces. Con Idempotency-Key derivada do pedido non pasa nada; sen ela, tes dúas facturas. Esa é toda a diferenza.
O NIF, que WooCommerce non che pide
Unha tenda de WooCommerce recentemente instalada pregunta nome, enderezo e correo. Non pregunta o NIF, porque o formulario de facturación é o estándar internacional e non sabe nada de España.
E sen NIF non hai factura completa: hai factura simplificada, que ten tope de importe e que o teu cliente empresa non pode deducir. Así que antes de escribir unha liña de integración hai que engadir ese campo ao proceso de compra, con calquera dos complementos de facturación española que hai, ou a man co gancho de campos de facturación de WooCommerce.
Despois, no código de abaixo, ese campo é o que viaxa a nif do contacto. Se chega baleiro, decide que fas: crear o contacto sen NIF e emitir simplificada, ou parar e avisar. O que non hai que facer é inventarse un NIF de recheo, que é o que acaba descadrando o 347 en febreiro.
O enganche, enteiro
Isto vai nun plugin propio —un ficheiro en wp-content/plugins/—, non no functions.php do tema: o día que cambies de tema non queres perder a 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());
}
}Os cinco detalles que non se ven no código
- A clave, nunha variable de contorno. No código de arriba lese con
getenv. Se a pos no ficheiro do plugin, viaxa ao teu repositorio e a cada copia de seguranza. E desde logo non vai na base de datos de WordPress cun campo de opcións visible. - O
tipo_iva, da liña e non fixo. O exemplo pon21para que se lea. Se vendes libros, alimentación ou servizos con tipos distintos, saca o tipo dos impostos de cada liña do pedido; poñer 21 en todo é un erro contable, non de programación. E se a túa empresa está en Canarias, o tipo que toca é o do IGIC:GET /organizaciondiche en que territorio estás, enregion_fiscal. - Se a túa tenda traballa con prezos co IVE dentro, non o detalles ti. Manda os prezos da etiqueta e engade
"precios_con_iva" => trueao corpo da factura: Cairos saca a base cara a atrás en céntimos enteiros, co mesmo motor que usa o conector de tendas. O detalle escrito a man faise case sempre cara a diante, e ese céntimo de menos acumúlase factura a factura ata que o 303 non cadra coa caixa. - Os gastos de envío tamén facturan. Non están en
get_items(): hai que engadilos como unha liña máis, co seu tipo de IVE, que en España é o do ben principal. - As devolucións non se resolven aquí. Un reembolso en WooCommerce non borra unha factura emitida: fai falta unha rectificativa. Hoxe iso faise desde Cairos, a man. É honesto dicilo antes de que alguén monte a tenda contando con que se amaña só.
Como probalo sen enlixar a túa facturación
Coa clave cai_test_, que traballa sobre os teus mesmos datos pero non rexistra en VeriFactu nin manda correos. E cunha serie de facturación aparte —PRUEBAS, por exemplo— para que os números das túas probas non se mesturen cos da túa serie de verdade.
Despois, un pedido real dun euro coa túa propia tarxeta e comprobar tres cousas: que aparece a factura, que o número está gardado no pedido, e que repetir o gancho non crea unha segunda. Esa terceira é a que hai que probar de verdade, e próbase chamando dúas veces á función a man.
Preguntas sobre WooCommerce
woocommerce_payment_complete, ou ao completalo se vendes produto físico. Nunca ao crear o pedido: un carriño coa pasarela aberta pode non se pagar nunca.Idempotency-Key: wc-1042. A primeira evita o reproceso; a segunda, o corte de rede. Está explicado en idempotencia.Este código funciona hoxe: crea a clave e próbao
Está escrito contra os nomes de campo que o servidor acepta de verdade. Cunha clave de probas e unha serie aparte podes lanzalo vinte veces sen enlixar nada.
PHP sen dependencias · Idempotency-Key · Serie de probas aparte