Saltar al contenido principal

Facturación electrónica (invoicing)

Xenpia emite facturas electrónicas de Ecuador a través de una base Odoo dedicada (facturacionEc), que no es la de billing del SaaS ni la de ningún cliente. La interfaz es nativa de Xenpia; el usuario nunca entra a Odoo.

Es un módulo de la plataforma con código invoicing, categoría finance y sin dependencias: genérico, sirve a una clínica igual que a un ecommerce. El cliente es siempre un contacto de la plataforma y cada línea lleva su impuesto; enlazar una línea con un producto de catálogo es el siguiente paso natural. Nació como un menú dentro de Salud (health.invoices) y se sacó de ahí: ver la decisión 0003.

Piezas​

CapaDónde
Manifiestobackend/src/modules/manifests.ts → invoicingManifest
APIbackend/src/modules/invoicing/ (InvoicingController, InvoicingService, FacturacionEcClient)
Tiposshared/src/modules/invoicing.ts
Panelfrontend/src/app/dashboard/invoicing/, frontend/src/sections/invoicing/
Accionesfrontend/src/actions/invoicing.ts — todo pasa por el backend
Esquema20260911130000_invoicing_ec_mirror.sql (espejo), 20260930000101_invoicing_module.sql (módulo), 20261005000101_edi_invoices_contact.sql (cliente = contacto)
Pruebas SQLsupabase/tests/invoicing/

Dos llaves, independientes​

  • El módulo activo para el tenant (pantalla de Aplicaciones). Lo comprueban ModuleGuard en el backend y ModuleGuard en app/dashboard/invoicing/layout.tsx.
  • El plan con el extra: xemp.plan.invoicing_ec en Odoo billing, espejado en public.plans.invoicing_ec (default false, fail-closed). Lo comprueba InvoicingService.assertPlanAllows vía la RPC tenant_has_invoicing_ec. El panel lo pregunta con GET /api/invoicing/status y muestra "no incluida en tu plan" en vez de un error.

Si el panel dice "No incluida en tu plan"​

La RPC no consulta Odoo: lee el espejo (subscriptions activa del tenant → plans.invoicing_ec). Activar el extra en el plan de Odoo llega al espejo por dos caminos:

  1. El webhook del plan, que Odoo envía a xemp_subscriptions.backend_url. Si ese parámetro apunta a otro entorno, el espejo de este no se entera.
  2. Guardar la suscripción del tenant desde el panel de superadmin (Tenants → editar → Suscripción): desde 2026-09-14 re-espeja también el plan, no solo la suscripción.

Comprobarlo en el SQL Editor del entorno:

SELECT s.state, p.code, p.invoicing_ec, p.synced_at
FROM public.subscriptions s LEFT JOIN public.plans p ON p.id = s.plan_id
WHERE s.tenant_id = '<tenant_uuid>' ORDER BY s.current_period_start DESC NULLS LAST;

Sin fila active, o con plan_id nulo, el tenant no tiene suscripción espejada: asignarle el plan desde el panel. Con invoicing_ec = false y synced_at viejo, es el caso del webhook.

Barreras de la API​

El backend va con service-role, así que no hay RLS que filtre. Cada petición pasa por:

  1. ModuleGuard + @RequiresModule('invoicing') en todo el controlador.

  2. PermissionGuard con un permiso por operación:

    RutaPermiso
    GET /api/invoicing/status?tenant_id=pertenencia al tenant
    GET /api/invoicing/invoices?tenant_id=invoicing.invoices.read
    GET /api/invoicing/invoices/:engineId?tenant_id=invoicing.invoices.read
    GET /api/invoicing/invoices/:engineId/ride?tenant_id=invoicing.invoices.read
    POST /api/invoicing/invoicesinvoicing.invoices.issue
    POST /api/invoicing/invoices/syncinvoicing.invoices.read
    POST /api/invoicing/webhook/enginefirma HMAC del motor (público)
    GET /api/invoicing/issuers?tenant_id=invoicing.invoices.read
    POST /api/invoicing/issuersinvoicing.issuers.manage
    PATCH /api/invoicing/issuers/:issuerIdinvoicing.issuers.manage
    DELETE /api/invoicing/issuers/:issuerId?tenant_id=invoicing.issuers.manage
  3. Comprobaciones de pertenencia en el servicio, que un guard no puede hacer:

    • Cliente: contact_id es obligatorio y tiene que ser un contacto del tenant, no borrado (contact_not_found si no). El cliente que va al motor se arma desde la ficha con resolveInvoiceCustomer (invoice-customer.ts): si la ficha tiene nombre o documento, mandan ellos y uno distinto en el cuerpo responde 409 contact_identity_mismatch (facturar a otro nombre = elegir o crear otro contacto); lo que falta se toma del cuerpo y se escribe en contacts con service-role; sin documento en ninguno, 400 contact_document_required. La factura se espeja con edi_invoices.contact_id (20261005000101, ON DELETE SET NULL: borrar el contacto no borra la factura).
    • Líneas: normalizeInvoiceLines (invoice-lines.ts) exige descripción, cantidad > 0, precio ≥ 0 e impuesto obligatorio entre iva15, iva5, iva0. El motor asumía iva15 ante una línea sin impuesto; ya no llega ninguna sin él.
    • Emitir: el RUC del cuerpo tiene que estar en edi_issuers de ese tenant y activo; los datos del emisor (razón social, establecimiento, punto) salen de ese registro, no del cuerpo. Antes bastaba conocer el RUC de otra clínica para firmar con su certificado.
    • Emisores: el motor identifica al emisor solo por RUC y register_issuer reemplaza su certificado. Registrar un RUC que ya tiene otro tenant en edi_issuers responde 409 issuer_taken sin tocar el motor; el mismo tenant sí puede re-registrarlo (cambio de certificado o reactivación). Editar (PATCH) toma el RUC del registro, nunca del cuerpo: establecimiento y punto se guardan solo en el espejo; razón social, ambiente y datos tributarios van al motor con update_issuer (sin certificado); con certificado se re-registra.
    • Datos tributarios del emisor (20261005000102): dirección matriz (dirMatriz / dirEstablecimiento, obligatoria; sin ella el SRI devuelve error 35), nombre comercial, obligado a llevar contabilidad (el motor asume SI si nadie lo declara), contribuyente especial y régimen RIMPE. En el motor los traduce issuer_vals.company_vals a res.company. Emitir con un emisor sin dirección responde 400 issuer_address_required antes de llamar al motor. Eliminar (DELETE) pone active = false: en el motor no se borra nada, las facturas cuelgan de él.
    • Consultar / RIDE: la factura tiene que estar en edi_invoices de ese tenant; si no, 404 sin llamar al motor. El id del motor es un entero correlativo: probar ids era leer facturas ajenas.

Los 403 propios llevan error_code (plan_disabled, issuer_not_registered); el panel los traduce con classifyInvoicingError (sections/invoicing/invoicing-errors.ts).

Estado SRI al día: tres vías​

El motor espera al SRI dentro de issue_invoice (recepción y autorización), así que casi todas las facturas vuelven autorizadas o rechazadas al emitir. Las que el SRI deja EN PROCESO las reintenta el cron de xemp_l10n_ec_edi cada 15 min. Para que Xenpia se entere:

  1. Aviso del motor (segundos). xemp_invoicing_ec encola un xemp.invoicing.event (outbox) al cambiar estado SRI, error, autorización o RIDE; lo entrega tras el commit y lo reintenta con espera creciente (cron de 1 min). POST /api/invoicing/webhook/engine es público y se autentica con HMAC-SHA256 sobre account.move:<id>:<tenant> (el tenant va dentro de la firma). Nunca se confía en el cuerpo: se re-consulta con get_invoice.
  2. Al abrir la lista. Si hay pendientes, el panel llama a POST /api/invoicing/invoices/sync y repinta si algo cambió. "Actualizar" hace lo mismo.
  3. Reconciliación. InvoicingReconcileScheduler consulta cada 10 min las pendientes de todos los tenants (últimos 30 días).

refreshInvoice rechaza una respuesta del motor de otro tenant y su upsert no toca contact_id ni la correlación: solo el estado.

Configuración (por entorno):

DóndeClaveValor
.env del backendFACTURACION_EC_WEBHOOK_SECRETsecreto compartido
Odoo facturacionEc, parámetro del sistemaxemp_invoicing_ec.webhook_secretel mismo secreto
Odoo facturacionEc, parámetro del sistemaxemp_invoicing_ec.xenpia_backend_urlbackend de ese entorno, sin /api (Test: https://apitest.xenpia.com)

Sin estos parámetros el motor no encola avisos y quedan las vías 2 y 3. Los avisos que no se entregan se ven en el motor en el modelo xemp.invoicing.event (estado y último error).

Espejo local​

edi_invoices y edi_issuers guardan lo que devuelve el motor para listar sin llamarlo. Solo los escribe y lee el backend: 20260930000101 revocó el SELECT que tenían anon y authenticated, porque las facturas llevan nombre y cédula del paciente y ningún flujo las leía con la sesión.

El certificado .p12 y su clave nunca se guardan en Xenpia: van una vez al motor.

Motor facturacionEc​

HostBD
ProdfacturacionEc.xenpdoo.comfacturacionEc
Testfacturacionec.testxenpia.comsegún dbfilter

Variables del backend, propias y sin reutilizar las ODOO_* de billing (una confusión de Host facturaría en la base contable de Xenpia):

FACTURACION_EC_ODOO_BASE_URL=
FACTURACION_EC_ODOO_HOST_HEADER=facturacionEc.xenpdoo.com
FACTURACION_EC_ODOO_API_KEY=

Van en el .env de la raíz del proyecto en cada servidor (docker-compose.yml lo carga con env_file); tras editarlo, docker compose up -d backend. Sin BASE_URL o sin API_KEY la API responde 503 y el panel muestra engine_unavailable. HOST_HEADER es opcional: vacío usa el host de la URL.

Acciones del puente (addon Odoo xemp_invoicing_ec): issue_invoice, get_invoice, download_ride, register_issuer, update_issuer, list_invoices. Los contactos se crean bajo demanda por (xenpia_tenant_id, vat); no hay sincronización con el CRM.

Ver las facturas en el motor​

Viven en la base facturacionEc (Test: facturacionec.testxenpia.com), no en la de billing. Cada emisor es una empresa de Odoo y solo se ven los registros de las empresas permitidas del usuario. Desde xemp_invoicing_ec 19.0.1.3.0, registrar o editar un emisor habilita su empresa a los administradores; para un emisor anterior, edítalo una vez desde Xenpia o añade la empresa a mano en el usuario. Luego: marcar la empresa en el selector → Contabilidad → Clientes → Facturas → filtro Emitidas desde Xenpia (o agrupar por Tenant Xenpia).

Migrar y probar​

20260930000101_invoicing_module.sql es idempotente y:

  • da de alta invoicing en app_modules y sus tres permisos;
  • renombra el menú health.invoices a invoicing.invoices (conserva el id) y añade invoicing.issuers, en la sección invoicing;
  • activa el módulo donde había uso real (plan con el extra, emisores o facturas registradas);
  • reparte permisos: admin/administrador/owner reciben los tres; quien tenía health.records.manage conserva consultar y emitir, no gestionar emisores.
cd supabase/tests/invoicing
./run.sh # ROJO: estado de DEV
./run.sh ../../migrations/20260930000101_invoicing_module.sql ../../migrations/20261005000101_edi_invoices_contact.sql # VERDE, aplicadas dos veces