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
| Capa | Dónde |
|---|---|
| Manifiesto | backend/src/modules/manifests.ts → invoicingManifest |
| API | backend/src/modules/invoicing/ (InvoicingController, InvoicingService, FacturacionEcClient) |
| Tipos | shared/src/modules/invoicing.ts |
| Panel | frontend/src/app/dashboard/invoicing/, frontend/src/sections/invoicing/ |
| Acciones | frontend/src/actions/invoicing.ts — todo pasa por el backend |
| Esquema | 20260911130000_invoicing_ec_mirror.sql (espejo), 20260930000101_invoicing_module.sql (módulo), 20261005000101_edi_invoices_contact.sql (cliente = contacto) |
| Pruebas SQL | supabase/tests/invoicing/ |
Dos llaves, independientes
- El módulo activo para el tenant (pantalla de Aplicaciones). Lo comprueban
ModuleGuarden el backend yModuleGuardenapp/dashboard/invoicing/layout.tsx. - El plan con el extra:
xemp.plan.invoicing_ecen Odoo billing, espejado enpublic.plans.invoicing_ec(defaultfalse, fail-closed). Lo compruebaInvoicingService.assertPlanAllowsvía la RPCtenant_has_invoicing_ec. El panel lo pregunta conGET /api/invoicing/statusy 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:
- 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. - 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:
-
ModuleGuard+@RequiresModule('invoicing')en todo el controlador. -
PermissionGuardcon un permiso por operación:Ruta Permiso GET /api/invoicing/status?tenant_id=pertenencia al tenant GET /api/invoicing/invoices?tenant_id=invoicing.invoices.readGET /api/invoicing/invoices/:engineId?tenant_id=invoicing.invoices.readGET /api/invoicing/invoices/:engineId/ride?tenant_id=invoicing.invoices.readPOST /api/invoicing/invoicesinvoicing.invoices.issuePOST /api/invoicing/invoices/syncinvoicing.invoices.readPOST /api/invoicing/webhook/enginefirma HMAC del motor (público) GET /api/invoicing/issuers?tenant_id=invoicing.invoices.readPOST /api/invoicing/issuersinvoicing.issuers.managePATCH /api/invoicing/issuers/:issuerIdinvoicing.issuers.manageDELETE /api/invoicing/issuers/:issuerId?tenant_id=invoicing.issuers.manage -
Comprobaciones de pertenencia en el servicio, que un guard no puede hacer:
- Cliente:
contact_ides obligatorio y tiene que ser un contacto del tenant, no borrado (contact_not_foundsi no). El cliente que va al motor se arma desde la ficha conresolveInvoiceCustomer(invoice-customer.ts): si la ficha tiene nombre o documento, mandan ellos y uno distinto en el cuerpo responde409 contact_identity_mismatch(facturar a otro nombre = elegir o crear otro contacto); lo que falta se toma del cuerpo y se escribe encontactscon service-role; sin documento en ninguno,400 contact_document_required. La factura se espeja conedi_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 entreiva15,iva5,iva0. El motor asumíaiva15ante una línea sin impuesto; ya no llega ninguna sin él. - Emitir: el RUC del cuerpo tiene que estar en
edi_issuersde 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_issuerreemplaza su certificado. Registrar un RUC que ya tiene otro tenant enedi_issuersresponde409 issuer_takensin 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 conupdate_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 traduceissuer_vals.company_valsares.company. Emitir con un emisor sin dirección responde400 issuer_address_requiredantes de llamar al motor. Eliminar (DELETE) poneactive = false: en el motor no se borra nada, las facturas cuelgan de él. - Consultar / RIDE: la factura tiene que estar en
edi_invoicesde ese tenant; si no,404sin llamar al motor. El id del motor es un entero correlativo: probar ids era leer facturas ajenas.
- Cliente:
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:
- Aviso del motor (segundos).
xemp_invoicing_ecencola unxemp.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/enginees público y se autentica con HMAC-SHA256 sobreaccount.move:<id>:<tenant>(el tenant va dentro de la firma). Nunca se confía en el cuerpo: se re-consulta conget_invoice. - Al abrir la lista. Si hay pendientes, el panel llama a
POST /api/invoicing/invoices/syncy repinta si algo cambió. "Actualizar" hace lo mismo. - Reconciliación.
InvoicingReconcileSchedulerconsulta 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ónde | Clave | Valor |
|---|---|---|
.env del backend | FACTURACION_EC_WEBHOOK_SECRET | secreto compartido |
Odoo facturacionEc, parámetro del sistema | xemp_invoicing_ec.webhook_secret | el mismo secreto |
Odoo facturacionEc, parámetro del sistema | xemp_invoicing_ec.xenpia_backend_url | backend 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
| Host | BD | |
|---|---|---|
| Prod | facturacionEc.xenpdoo.com | facturacionEc |
| Test | facturacionec.testxenpia.com | segú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
invoicingenapp_modulesy sus tres permisos; - renombra el menú
health.invoicesainvoicing.invoices(conserva el id) y añadeinvoicing.issuers, en la seccióninvoicing; - 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.manageconserva 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