Ritual ERP Ritual ERP

Ritual ERP · Plataforma

Manual para desarrolladores

Cómo conectar apps y webs a Ritual ERP y cómo construir módulos dentro del sistema — manteniendo el aislamiento entre clientes y la seguridad a nivel senior que el ERP ya garantiza. Todo lo de aquí está vivo en producción.

Base URL  https://ritualerp.ch  ·  JSON  ·  TLS obligatorio

01 Fundamentos

El vocabulario y los formatos que se repiten en toda la API.

Multi-tenantCada centro (tenant) es un mundo aislado. Toda petición se resuelve a un centro y nunca ve datos de otro — lo garantiza la base de datos (RLS), no un where.
DineroEnteros en la unidad menor (Rappen). Los campos terminan en Minor: grossMinor: 12500 = CHF 125.00. Sin coma flotante.
FechasISO 8601 en UTC (2026-07-25T09:30:00.000Z). Zona de negocio: Europe/Zurich.
TextoUTF-8 completo (ñ, tildes, acentos).
MonedaCHF.
ErroresCódigo HTTP + cuerpo { "error": "…" }. 400 validación · 401 sin autenticar · 403 sin permiso/scope · 404 no existe/ajeno · 409 conflicto de idempotencia · 422 regla de negocio · 429 rate-limit.
Contrato vivo

La especificación OpenAPI 3.1 se genera desde el código (no se desincroniza) y es importable en Postman / Insomnia / Swagger:

GET /v1/developer/openapi.json      # requiere una sesión ADMIN

02 Los cimientos de seguridad

No son opcionales ni "buenas prácticas": es cómo está construido el sistema. Si escribes código o consumes la API, respétalos.

Aislamiento por centro — Row-Level Security (RLS)

Cada tabla con tenantId tiene RLS forzada en Postgres. El runtime corre con un rol sin privilegios (ritual_app) y toda operación pasa por withTenant(tenantId, tx => …), que fija app.current_tenant para esa transacción. La política de la tabla hace el resto:

ALTER TABLE "Mitabla" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "Mitabla" FORCE  ROW LEVEL SECURITY;
CREATE POLICY "Mitabla_tenant_isolation" ON "Mitabla"
  USING      ("tenantId" = current_setting('app.current_tenant', true))
  WITH CHECK ("tenantId" = current_setting('app.current_tenant', true));
GRANT SELECT, INSERT, UPDATE, DELETE ON "Mitabla" TO ritual_app;
Regla · no negociable

Toda tabla nueva con tenantId DEBE registrarse en TENANT_TABLES y llevar RLS + GRANT en una migración. Una tabla de centro sin RLS es una fuga de datos entre clientes. El test rls-coverage.spec falla si te la saltas.

Secretos, dinero e integridad

Cadena de auditoría

Cada hecho con consecuencias (cobros, facturas, notas de crédito, cancelaciones, nóminas, cierres, movimientos de paquetería…) se añade a una cadena append-only por centro: cada evento encadena el hash del anterior desde un génesis, y el append se serializa con un cerrojo de Postgres para que dos operaciones simultáneas no puedan bifurcarla. Alterar o borrar un registro por detrás rompe la cadena y se detecta.

GET/v1/auditleer el registro (type, take, before=seq) — ADMIN/ACCOUNTANT
GET/v1/audit/verifyintegridad → { ok, count, brokenAt? }

Va del más reciente hacia atrás y se pagina con before (el seq del último leído). Los hashes no se devuelven: son el mecanismo de integridad, no información de pantalla.

Los accesos de soporte también se auditan

Cuando el operador de la plataforma entra en el panel de un centro, queda un evento SUPPORT_ACCESS en la cadena de ese centro, con quién entró y como qué usuario — visible para el propio centro en Ajustes → Registro de actividad. Si no se puede dejar constancia, no se entra: un acceso silencioso es justo lo que esto impide.

03 Las cuatro credenciales

Elige la correcta según quién llama y a qué necesita acceder. No mezcles: cada una tiene un alcance distinto a propósito.

CredencialFormato / cabeceraParaAlcance
Sesión de usuario JWT
Authorization: Bearer <jwt>
El panel (personas). El rol del usuario (ADMIN, RECEPTION…).
Clave de API (M2M) rk_<tenant>_<keyId>_<secret>
Authorization: Bearer rk_…
Apps y módulos externos (servidor). Scopes por módulo. Nunca toca administración.
Clave de integración trk_live_<hex>
X-Api-Key: trk_live_…
Server-to-server: seguimiento, calculadora de envíos y emitir sesiones de portal. Solo /public/tracking*, /public/paqueteria/* y /public/portal/session.
Token de portal cst_<tenant>_<id>_<secret>
Authorization: Bearer cst_…
El navegador del cliente final. Solo los datos de ESE cliente. Corto (60 min), revocable.

04 Conectar una app o web (API M2M)

Para que tu backend hable con el ERP: crea una clave de API, dale scopes por módulo y llama a los recursos.

1 · Crea la clave y dale scopes

En el panel → Desarrolladores (o por API, rol ADMIN). Un scope es <modulo>:read o <modulo>:write (write incluye read), o * para todo. El método HTTP decide la acción: GET/HEAD = read; el resto = write.

GET/v1/developer/api-keyslistar
POST/v1/developer/api-keyscrear (devuelve el token 1 vez)
DEL/v1/developer/api-keys/:idrevocar

Módulos con scope: agenda, clientes, ventas, servicios, vales, avisos, chat, terapeutas, inventario, marketing, contactos, facturas, contabilidad, nomina, b2b, comercial, recepcion (paquetería).

Barrera dura

Una clave de API solo alcanza rutas de un módulo de negocio. Toda la administración (usuarios, ajustes, plataforma, developer, copias de seguridad, suscripción) no está mapeada a ningún módulo y devuelve 403 siempre — una clave nunca llega ahí.

2 · Autentica y llama

# Listar clientes del centro dueño de la clave
curl https://ritualerp.ch/v1/customers?limit=50&offset=0 \
  -H "Authorization: Bearer rk_<tenant>_<keyId>_<secret>"

# Crear un cliente (write). En operaciones de dinero, añade Idempotency-Key.
curl -X POST https://ritualerp.ch/v1/customers \
  -H "Authorization: Bearer rk_…" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Juan Pérez", "email": "juan@ejemplo.ch" }'

Recursos documentados en el OpenAPI: /v1/bookings, /v1/customers, /v1/services, /v1/products, /v1/invoices, /v1/sales, /v1/vouchers, /v1/jobs. Otros módulos son alcanzables con su scope (p. ej. paquetería: /v1/recepciones, /v1/bultos, /v1/tarifas… con recepcion:*).

Trabajos (Aufträge). Un trabajo es un encargo con principio y fin: se presupuesta, se ejecuta en varias citas y se factura al final. Scope trabajos:read / trabajos:write. Las etapas del pipeline las define cada centro en /v1/job-stages; mover un trabajo a una etapa terminal lo cierra (closedAt + outcome) y volver a una activa lo reabre.

GET/v1/jobsabiertos (?open=false = cerrados)
GET/v1/jobs/:idficha + citas, horas, material, gastos, facturas y margen
POST/v1/jobsabrir (Idempotency-Key)
POST/v1/jobs/:id/stagemover de etapa
POST/v1/jobs/:id/timeimputar horas
POST/v1/jobs/:id/materialsacar material del almacén contra el trabajo
POST/v1/jobs/:id/expensesgasto imputado (subcontrata, permiso…)
GET/v1/jobs/:id/linkable-bookingscitas del cliente aún sin trabajo
POST/v1/jobs/:id/bookings/:bookingIdenlazar una cita como sesión
POST/v1/quotes/:id/acceptaceptar la oferta y abrir su trabajo · devuelve { accepted, job } · job es null si el centro no tiene el módulo trabajos (scope comercial:write)

El coste de un trabajo se compone solo: las horas que le imputas (valoradas al coste/hora de cada persona), el material que sale de almacén y los gastos que le asignes. Horas y material se congelan al imputarlos —con la tarifa y el coste medio de ese momento—, de modo que una subida de tarifa o una compra posterior no reescriben el margen de un trabajo ya cerrado. Si alguien no tiene coste/hora, sus horas entran a 0 y la respuesta lo señala con costing.hasUnpricedLabor. Un trabajo cerrado rechaza toda imputación: hay que reabrirlo moviéndolo a una etapa activa.

Además, jobId se acepta directamente al crear una reserva (POST /v1/bookings), un movimiento de stock (POST /v1/products/:id/movements), un gasto (POST /v1/expenses) y una factura (POST /v1/invoices y /v1/invoices/multi). Varias facturas pueden apuntar al mismo trabajo: anticipo, hitos y liquidación final; su suma es costing.invoicedNetMinor.

Contratos de mantenimiento. Un contrato no termina: cobra una cuota periódica y cubre revisiones. Cada intervención es un trabajo hijo (Job.agreementId), así que hereda etapas, horas, material y margen. Su rentabilidad se mide ACUMULADA (cuota devengada + facturado aparte − coste de las intervenciones), no al cerrar. Mismo scope trabajos:*. No confundir con /v1/contracts, que son los acuerdos B2B de tarifas con hoteles.

GET/v1/service-agreementsactivos (?active=false = todos)
GET/v1/service-agreements/:idficha + intervenciones + rentabilidad y aviso de renovación
POST/v1/service-agreementsalta (Idempotency-Key)
PATCH/v1/service-agreements/:ideditar / desactivar
POST/v1/service-agreements/:id/generatecrear las revisiones del año como trabajos · idempotente por (contrato, año)

Paginación: ?limit= (1–500, def. 100) y ?offset= (def. 0).  Dinero: añade Idempotency-Key: <8–200 chars>, estable por intento.

Fidelidad: acreditar puntos

Con el scope de clientes (clientes:write). El saldo, el nivel y la equivalencia en francos los manda el ERP en GET /portal/me (portal) y en GET /v1/customers/:id/loyalty (server-to-server). Para SUMAR puntos —recomendación, promo, ajuste— una sola ruta (la BIENVENIDA la da el ERP solo, no por aquí):

POST/v1/loyalty/creditabona puntos · idempotente por externalRef · devuelve el saldo
POST/v1/loyalty/reverserevierte un abono por su externalRef (signo contrario, no borra; nunca deja saldo negativo)
GET/v1/customers/:id/loyaltysaldo, nivel y equivalencia de un cliente (server-to-server)
GET/v1/customers/:id/loyalty/transactionslibro de movimientos del cliente (los 200 más recientes: motivo, puntos con signo, saldo resultante)
POST/v1/customers/:id/loyalty/adjustajuste manual del operador: points con signo (negativo = restar) + note; deja rastro MANUAL, nunca deja saldo negativo
POST /v1/loyalty/credit      Authorization: Bearer rk_…
     { "customerId": "cms0…", "points": 50, "reason": "REFERRAL",
       "note": "Recomendó a AX-00412", "externalRef": "ref-cms0-cmt0" }
  → { "balance": 150, "created": true, "transaction": { … } }   # 201 al crear · 200 si el mismo externalRef ya existía

reason: REFERRAL · PROMO · MANUAL (cerrado; WELCOME es exclusivo del ERP). externalRef es OBLIGATORIO: es la clave de idempotencia. Reenviar el MISMO externalRef devuelve el movimiento existente sin volver a acreditar (como las pre-alertas). Rechaza puntos no positivos, cliente archivado y superar el tope de puntos por abono del centro. La BIENVENIDA la da el ERP al crear la ficha (cantidad configurable por centro, en cualquier canal) — no la acredites tú, o se duplicaría; usa la ruta solo para recomendación/promo/manual. Los puntos no caducan. Las reglas del programa (cuántos da una compra, niveles, equivalencia) y el canje son del ERP. Cada abono o reverso dispara el webhook loyalty.updated (con customerId y balance), para avisar al cliente sin preguntar en bucle.

Contabilidad: inmovilizado y concordancia de IVA

La contabilidad completa es alcanzable con contabilidad:read / contabilidad:write: además de balances, diario y liquidación de IVA, están el inmovilizado (activar un bien y amortizarlo durante su vida útil, OR 960a párr. 3) y la concordancia anual de IVA (art. 72 LIVA). Las escrituras piden Idempotency-Key.

GET/v1/accounting/assetsbienes con su valor contable · ?includeDisposed=1 incluye las bajas · trae las clases y sus tasas ESTV
POST/v1/accounting/assetsactivar: name, assetClass, acquisitionDate, netMinor; opcionales method, rateBps, residualMinor, paymentAccount
GET/v1/accounting/assets/:idficha: amortizaciones contabilizadas + plan previsto año a año
POST/v1/accounting/assets/previewplan previsto sin dar de alta nada (misma función que luego contabiliza)
POST/v1/accounting/assets/depreciation-runamortizar el ejercicio: year. Idempotente por (bien, año): repetirlo no amortiza dos veces
POST/v1/accounting/assets/:id/disposebaja: date y kind = SALE (repercute MWST, cifra 302) · SCRAP · PRIVATE (autoconsumo art. 31 sobre el valor fiscal)
GET/v1/accounting/anlagespiegelcuadro del inmovilizado del ejercicio: ?year=
GET/v1/accounting/vat-filingsliquidaciones ya presentadas a la ESTV: ?year=
POST/v1/accounting/vat-filingsmarcar un periodo como presentado y congelar sus cifras: year, period (Q1Q4, S1, S2)
DELETE/v1/accounting/vat-filings/:iddeshacer el marcado
GET/v1/accounting/vat-reconciliationconcordancia del ejercicio (?year=): deriva por periodo, Umsatzabstimmung por cuenta, importe a regularizar y fecha límite

La concordancia necesita que cada periodo se marque como presentado cuando se presenta. La liquidación se recalcula siempre del diario, así que sin esa foto se compararía consigo misma y siempre daría cero; lo que un control busca es lo contrario, los asientos que entraron en un periodo ya declarado. Un ejercicio se declara por trimestres o por semestres, nunca mezclados (422 si se intenta).

La liquidación devuelve el formulario completo de la ESTV: turnoverTotalMinor (200), bases taxableBase* (302/303/305), exemptBaseMinor (230), outputVatMinor (399), inputVatMaterialMinor (400), inputVatInvestMinor (405), inputVatDepositMinor (410), inputVatCorrectionMinor (415, resta), inputVatTotalMinor (479) y vatPayableMinor (500). Solo el método efectivo: el de tipo de saldo (Saldosteuersatz) no está implementado.

Vales de plataforma (DeinDeal, Groupon…)

Una plataforma vende cupones de tus servicios, cobra al cliente y te liquida el neto más tarde. Es una entidad con su comisión por defecto y su cuenta de deudor; los cupones se cuelgan de ella. Scope vales:read / vales:write.

GET/v1/platformslistar con sus cifras · ?status=all incluye inactivas
POST/v1/platformsalta: name, code, commissionBps, commissionMode
PATCH/v1/platforms/:ideditar (el código no cambia)
GET/v1/platforms/:id/settlementsliquidaciones registradas
POST/v1/platforms/:id/settlementsregistrar la transferencia: date, receivedMinor, voucherIds

Al crear un vale, platformId sustituye a channel y la comisión se calcula sola con el % de la plataforma si no mandas commissionMinor. commissionMode vale ISSUE (la comisión va a gasto al emitir) o SETTLEMENT (entra al liquidar, con su IVA soportado). Sin platformId, channel sigue funcionando como antes.

La liquidación admite además customerPackIds: si la plataforma vendió opciones de varias sesiones, esos bonos son deuda suya igual que los cupones y se cobran en el mismo asiento. Sin voucherIds ni customerPackIds se liquida todo lo pendiente de esa plataforma.

Ofertas de plataforma y sus opciones

Una oferta es el deal publicado (una página con varias líneas comprables). Cada opción lleva servicio, nº de sesiones, precio regular y lo que paga el cliente. Emitir desde una opción decide la forma: sessions = 1 crea un vale; más de una, un bono de sesiones (el cliente compró sesiones, no un saldo). Scope vales:read / vales:write.

GET/v1/platform-dealsofertas con opciones y cifras · ?status=all
POST/v1/platform-dealsalta: platformId, name, endsAt
PATCH/v1/platform-deals/:ideditar / cerrar la campaña (active)
POST/v1/platform-deals/:id/optionsañadir opción: label, serviceId, sessions, regularMinor, paidMinor
PATCH/v1/platform-deals/options/:ideditar la opción
POST/v1/platform-deals/options/:id/issueemitir lo comprado: code, customerId · requiere Idempotency-Key
GET/v1/platform-deals/lookup?code=qué se emitió con ese código (vale o bono)
# El cliente llega con su código: se emite lo que compró y se devuelve qué salió.
curl -X POST https://ritualerp.ch/v1/platform-deals/options/OPT_ID/issue \
  -H "Authorization: Bearer rk_…" \
  -H "Idempotency-Key: dd-7F3A-2026-08" \
  -H "Content-Type: application/json" \
  -d '{ "code": "DD-7F3A", "customerId": "cus_…" }'

# → { "kind": "PACK", "voucherId": null, "customerPackId": "cpk_…" }

El mismo code no se puede emitir dos veces (422): escanear por error no duplica la deuda de la plataforma. Una opción de varias sesiones exige customerId — un bono es el saldo de sesiones de alguien. El ERP no valida el cupón: de eso se encarga la app de la plataforma.

Pre-alertas · listas de empaque (paquetería)

Una pre-alerta es la declaración del contenido de un envío que llega antes que el bulto físico: tu web la empuja al finalizarla y, al recepcionar, Ritual la vincula al bulto por su código. Se consume con clave rk_: scope recepcion:write (crear / vincular) o recepcion:read (consultar).

POST/v1/packing-listscrear / actualizar (idempotente por code)
GET/v1/packing-listslistar · ?status= & ?customerId=
GET/v1/packing-lists/:codeconsultar por código
GET/v1/packing-lists/:code/pdfPDF imprimible con su código de barras (Code128)
DELETE/v1/packing-lists/:codeborrar una lista aún en SUBMITTED (no vinculada)
POST/v1/packing-lists/:code/linkvincular al bulto → webhook
# La web empuja la lista al finalizarla. Si mandas "code" es idempotente
# (reenviar el mismo code NO duplica); si lo omites, Ritual genera un PKL-AÑO-NNNNN.
curl -X POST https://ritualerp.ch/v1/packing-lists \
  -H "Authorization: Bearer rk_…" -H "Content-Type: application/json" \
  -d '{
    "code": "LE-2026-00042",
    "customerId": "<id del cliente en Ritual>",
    "destination": "La Habana",
    "sender":    { "name": "Remitente" },
    "recipient": { "name": "Destinatario", "municipio": "Centro Habana" },
    "items": [ { "description": "Camiseta", "qty": 2, "weightGrams": 400, "declaredValueMinor": 3500, "category": "Ropa", "bag": "1" } ]
  }'

# Al recepcionar el bulto físico: vincúlala por bultoCode (o bultoId)
curl -X POST https://ritualerp.ch/v1/packing-lists/LE-2026-00042/link \
  -H "Authorization: Bearer rk_…" -H "Content-Type: application/json" \
  -d '{ "bultoCode": "BLT-2026-01234" }'
  → { "code": "LE-2026-00042", "status": "LINKED", "bultoCode": "BLT-2026-01234", "changed": true }

# Opcional: "syncItemsFromBulto": true hace que la lista ADOPTE los artículos del bulto
# (descripción/cantidad/peso). Lo usa el panel al EDITAR una recepción; el valor declarado
# del cliente se conserva emparejando por descripción.

La respuesta incluye trackingCode (13 díg.): el número de seguimiento del cliente, listo para enseñar/imprimir, estable toda la vida del paquete y buscable en /public/tracking. Si la lista se crea en recepción (sin push previo), ese número llega en el webhook packinglist.linked. Estados: SUBMITTED (declarada) → LINKED (vinculada al bulto) → SHIPPEDDELIVERED. Reenviar un code ya vinculado no pisa los datos: a partir de la recepción manda Ritual. Cada artículo lleva description (obligatorio) y, opcionales, qty, weightGrams, declaredValueMinor (en Rappen) y los metadatos category y bag (se conservan tal cual y salen en el PDF, bajo la descripción). sender/recipient son objetos libres (nombre + datos de contacto). El evento packinglist.linked (§06) se dispara al vincular por PRIMERA vez; reenviar el mismo code con cambios (respuesta 200) dispara packinglist.updated; la respuesta incluye changed (true = vínculo nuevo, false = ya estaba vinculado a ese bulto) y volver a enlazar al mismo bulto es idempotente: no re-dispara el webhook.

PDF con código de barras: GET /v1/packing-lists/:code/pdf devuelve la lista en PDF imprimible con un Code128 de su code. Se pega al envío; al recibir la mercancía, el operador escanea ese código en Recepción y la lista se vincula sola al bulto de esa recogida (el mismo paso link, sin teclear). Sirve con clave rk_ (scope recepcion:read) para imprimirlo desde tu web, o desde el panel del operador.

Clave atada a un cliente (para incrustar en la web del cliente final): al crear una clave en el panel → Desarrolladores puedes atarla a un cliente. Esa clave solo puede leer las listas de empaque de ese cliente: GET /v1/packing-lists devuelve únicamente las suyas (el filtro ?customerId= se ignora y se fuerza el suyo), y :code / :code/pdf de cualquier otro cliente responden 404. Además, una clave atada no alcanza ninguna otra ruta del módulo (recepciones, bultos, seguimiento…): responde 403. Así puedes exponer el PDF o la consulta directamente en el navegador del cliente sin filtrar las listas de los demás. Una clave sin atar es servidor-a-servidor y ve todo el módulo.

05 Entornos de prueba (sandbox)

Prueba tu integración en un centro AISLADO antes de tocar producción. No es un "modo test" sobre tus datos reales: es un centro propio, con aislamiento total por RLS.

Qué es

Un sandbox es un centro (tenant) separado: sus datos, usuarios y claves de API viven aparte y no tocan producción. Es el mismo aislamiento por centro que protege a un cliente de otro (ver §02), aplicado a tu banco de pruebas.

Cómo se crea y se entra

El administrador lo crea en Suscripción → Entorno de pruebas → Crear. Para entrar hay dos caminos: el botón «Entrar al entorno de pruebas» de esa misma tarjeta (un clic, sin cerrar sesión) o la pantalla de acceso con las mismas credenciales, eligiendo «… (Sandbox)» en el selector de centro.

GET/v1/sandboxestado (¿es sandbox?, ¿tiene uno?)
POST/v1/sandboxcrear (idempotente) — solo ADMIN
POST/v1/sandbox/enterentrar con la sesión actual — solo ADMIN
POST/v1/sandbox/resetvaciar sus datos y empezar de cero — solo ADMIN
Área del alumno (escuelas)

Un centro con cursos no tiene una web propia que autentique al alumno, así que además del modelo de integración hay una puerta directa: POST /public/<centro>/alumno/acceso con su email le manda por correo un enlace con su token de portal (mismo cst_…, misma caducidad). Con ese token, GET /portal/escuela devuelve sus cursos, próximas clases, asistencia y recuperaciones pendientes. La respuesta de /alumno/acceso es SIEMPRE la misma exista o no el email: si dijera lo contrario, cualquiera podría comprobar quién es alumno del centro.

La puerta va en un solo sentido

POST /v1/sandbox/enter devuelve un token de solo acceso al sandbox (sin refresco), así que la sesión de producción se queda intacta y volver es inmediato. No existe la ruta contraria, y es a propósito: desde dentro del sandbox no se salta a producción. Si se pudiera, quien tuviera acceso de administrador al banco de pruebas —por ejemplo un integrador externo— podría crear allí un usuario con el correo de un administrador de producción y entrar con él.

Reiniciar: desde Suscripción → Entorno de pruebas → Vaciar (o POST /v1/sandbox/reset) se borran TODOS los datos del sandbox y se deja limpio, sin tocar producción. El borrado está acotado por RLS al centro de pruebas.

Claves de API de prueba

Las claves creadas dentro del sandbox son claves de prueba reales: llevan el tenantId del sandbox embebido (rk_<sandbox>_…), así que solo ven y escriben datos del sandbox. Se generan y usan igual que en producción (§04, §05), pero contra el centro de pruebas.

Regla

Desarrolla y prueba SIEMPRE con la clave del sandbox; pasa a la de producción solo cuando la integración funcione. Una clave de sandbox nunca puede leer ni tocar datos de producción, ni al revés: son centros distintos, aislados por RLS.

El sandbox NO manda correo

Se crea con los avisos por email apagados, y es a propósito: un banco de pruebas se llena de clientes inventados, y cada dirección falsa que rebota ensucia la reputación de envío que comparten los centros de verdad. Si necesitas ver el contenido de un aviso, míralo en el historial de avisos — se guarda el texto completo aunque no se envíe.

06 Webhooks — reaccionar a eventos

En vez de sondear, suscribe un endpoint y Ritual te avisa cuando algo pasa. Cada entrega va firmada.

Suscribir y eventos

POST/v1/developer/webhooks{ url, events[], … }
GET/v1/developer/webhookslistar
PATCH/v1/developer/webhooks/:ideditar / habilitar
POST/v1/developer/webhooks/:id/pingprueba
POST/v1/developer/webhooks/:id/rotate-secretrotar secreto

Eventos disponibles (o *): booking.created · booking.updated · booking.cancelled · customer.created · customer.updated · loyalty.updated (cambió el saldo de puntos de un cliente: abono o reverso) · invoice.created · invoice.paid · sale.created · voucher.created · job.created (se abrió un trabajo, tanto a mano como al aceptar una oferta) · job.stage_changed (cambió de etapa; si la etapa es terminal trae también closedAt y outcome) · packinglist.linked (pre-alerta vinculada a su bulto en recepción) · packinglist.updated (pre-alerta ya declarada actualizada, mismo code). La URL debe ser https en producción (se bloquean IPs privadas y el endpoint de metadatos de la nube — anti-SSRF).

Tu endpoint recibe un POST con este sobre y estas cabeceras:

Headers
X-Ritual-Event:       invoice.paid
X-Ritual-Event-Id:    <uuid>          # idempotencia en tu lado
X-Ritual-Delivery-Id: <uuid>
X-Ritual-Signature:   t=<unix>,v1=<hmac>   # ver abajo

Body
{ "id": "<uuid>", "type": "invoice.paid", "tenantId": "…",
  "createdAt": "2026-07-25T…Z", "data": { /* el recurso */ } }

Verifica la firma (HMAC-SHA256) — obligatorio

La firma se calcula sobre <t>.<cuerpo-crudo>. Recalcúlala sobre el cuerpo sin parsear, compárala en tiempo constante y rechaza timestamps viejos (corta los ataques de repetición). Responde 2xx en menos de 10 s.

import { createHmac, timingSafeEqual } from 'node:crypto';

function verify(rawBody, header, secret) {
  const [t, v1] = header.split(',').map(p => p.split('=')[1]);
  if (Math.abs(Date.now()/1000 - Number(t)) > 300) return false;  // >5 min: fuera
  const mac = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  return v1.length === mac.length && timingSafeEqual(Buffer.from(v1), Buffer.from(mac));
}
Entrega fiable

Reintentos con backoff (10 s · 30 s · 2 min · 10 min · 30 min · 2 h; 6 intentos), cola de fallidos y auto-desactivación tras 20 fallos consecutivos. Usa el id del evento para aplicar idempotencia en recepción.

06b Reservas en tu web y correo

Cómo se incrusta el motor de reservas en la web del centro, y cómo salen los avisos a sus clientes.

Incrustar el widget

El widget se sirve desde nuestro dominio y se mete en la web del centro con un <iframe>. El enlace y el código ya montados están en Ajustes → Reservas online; llevan dentro el slug del centro y su token de sitio.

<iframe src="https://ritualerp.ch/reservas/?tenant=<slug>&t=<token>&lang=de"
        style="width:100%;max-width:560px;height:780px;border:0"
        loading="lazy" title="Termin buchen"></iframe>

Tres canales, uno por página: reservas (por defecto), &view=packs (comprar un bono dejando las citas puestas) y &view=contact. El idioma se hornea en el enlace con &lang= (de · fr · it · es · en).

Que no parezca un iframe

Dos parámetros más, para que el widget deje de ser un recuadro pegado encima de la web del centro:

Los dos son opcionales y por defecto están apagados, así que ningún embebido existente cambia de aspecto. En Ajustes → Reservas online salen ya como opciones del enlace que se copia.

Alto automático (opcional, recomendado)

Un <iframe> no se ajusta solo a su contenido: la altura del ejemplo de arriba es una conjetura. Si se pasa queda un hueco muerto debajo; si se queda corta aparece una barra de scroll dentro de la reserva. Por eso el widget publica su altura real con postMessage cada vez que cambia, y la web del centro solo tiene que escucharla:

window.addEventListener('message', (e) => {
  if (e.origin !== 'https://ritualerp.ch') return;      // solo nuestro dominio
  if (!e.data || e.data.type !== 'ritual:height') return;
  document.querySelectorAll('iframe').forEach((f) => {
    if (f.contentWindow === e.source) f.style.height = e.data.height + 'px';
  });
});

Solo se manda; el widget no acepta mensajes del padre, así que una web ajena no puede empujarlo a un tamaño absurdo. Quien no escuche sigue con su altura fija: esto no rompe ningún embebido existente.

Si tu web declara su propia CSP

Tiene que permitir frame-src https://ritualerp.ch, o el widget saldrá en blanco sin ningún error visible.

Dominio permitido

En Ajustes → Reservas online el centro puede declarar el dominio de su web. Si lo declara, las escrituras públicas (reservar, contactar, pedir un bono, enviar un formulario) solo se aceptan desde ahí y responden 403 · Origen no permitido desde cualquier otra.

Cómo se sabe desde dónde: el widget vive en un <iframe> de otro dominio, así que la cabecera Origin de sus llamadas somos siempre nosotros y no distingue nada. Lo que identifica a la página padre es su document.referrer, que el widget reenvía en X-Embed-Origin. El apex y el www se consideran la misma web.

Alcance real de esta comprobación

Un sitio no puede falsificar el referrer de otro (lo pone el navegador), pero sí puede suprimirlo: sin cabecera se deja pasar, porque el enlace directo que se comparte por WhatsApp tampoco tiene página padre. Sube el listón, no cierra la puerta. Lo que de verdad protege son el token de sitio, el reCAPTCHA, el honeypot y el límite por IP.

Cómo salen los avisos

Dos modos, en Ajustes → Avisos:

Con el dominio del centro verificado (DKIM comprobado contra el DNS), el correo gestionado sale además con la dirección del propio centro. Mientras no lo esté, sale con la de la plataforma: nunca se rompe el envío por esta causa.

Direcciones suprimidas

Un rebote definitivo o una queja de spam apuntan la dirección y el sistema deja de escribirle. Se consulta y se revierte desde Ajustes → Avisos o por API:

GET/v1/notifications/suppressionslas direcciones quemadas del centro — solo ADMIN
DELETE/v1/notifications/suppressions/:idrehabilitar una dirección — solo ADMIN

Un fallo temporal (4xx del servidor de destino, retraso de entrega) no quema ninguna dirección: solo los rechazos permanentes.

06c Calendario en el móvil

Sacar la agenda del panel y meterla en Apple, Google u Outlook. Dos rieles con propósitos distintos: una suscripción que funciona en los tres sin configurar nada, y una sincronización con Google de ida y vuelta.

Suscripción iCalendar (.ics)

El centro crea el enlace en Ajustes → Calendario, y cada persona el suyo en Perfil → Mi agenda en el móvil. La URL se pega como «calendario suscrito» en el teléfono.

GET/public/calendar/<token>/ritual.icsla agenda en iCalendar (RFC 5545) → text/calendar

Sin cabeceras de autenticación: la URL ES la credencial, porque un calendario suscrito no puede mandar ninguna. De ahí que todo lo demás sea estricto:

El detalle es del enlace, no del centro

FULL (cliente y servicio) · LIMITED (sin nombre de cliente) · BUSY (solo «ocupado»). Lo que se elija es lo que acaba guardado en los servidores de Google o Apple. Para datos de salud, publicar «ocupado» y nada más es la opción correcta por defecto.

Frescura: Apple sí, Google no

Apple refresca los calendarios suscritos cada pocos minutos. Google los refresca cuando le parece (típicamente horas): sirve para ver la semana, no para «acabo de mover una cita». Quien necesite Google al minuto tiene que usar la sincronización de abajo.

Gestión desde el panel (sesión, módulo agenda). El token se devuelve una sola vez, al crear o al rotar:

GET/v1/calendar/feedslos enlaces del centro (nunca el secreto)
POST/v1/calendar/feedscrear → devuelve token y path
PATCH/v1/calendar/feeds/:idcambiar ámbito, detalle o qué publica
POST/v1/calendar/feeds/:id/rotatenuevo token; el anterior deja de valer
DELETE/v1/calendar/feeds/:idrevocar
GET/v1/bookings/:id/icsuna cita suelta como .ics

Un profesional (rol THERAPIST) solo puede crear y ver enlaces de su propia agenda: el servidor ata el ámbito a su ficha, no se lo cree del cuerpo de la petición.

Leer un calendario de fuera para tapar huecos (sin OAuth)

La dirección contraria, y la que más se pide: que lo que una persona tiene en su calendario personal deje de ofrecerse como hueco libre. Pega la dirección privada iCal de su calendario (Google, Apple u Outlook) y Ritual la descarga cada pocos minutos. No necesita registrar ninguna app.

Del calendario ajeno solo entran HORAS

Inicio y fin. El título, los invitados y la ubicación se leen para saber si el evento ocupa —cancelado, «disponible», libre de Outlook— y se descartan en el acto: no se guardan, no se registran y no aparecen en la agenda.

GET/v1/calendar/externallas suscripciones del centro (nunca la URL)
POST/v1/calendar/externalalta: comprueba el enlace ANTES de guardarlo
PATCH/v1/calendar/external/:idnombre, ventana, activar/desactivar
POST/v1/calendar/external/:id/syncforzar una descarga ahora
DELETE/v1/calendar/external/:idquitar (los huecos vuelven al instante)

Ese «ocupado» resta disponibilidad —no se ofrece el hueco ni en la reserva online ni en las sugerencias— pero no bloquea al mostrador: un dato de fuera no puede dejar a recepción sin poder trabajar.

Sincronización con Google (OAuth)

Ida y vuelta de verdad, y con dos límites que son la parte importante del diseño:

GET/v1/calendar/google/status¿está configurada?, URL de retorno, permisos
POST/v1/calendar/google/connectdevuelve la URL de consentimiento (state firmado)
GET/public/calendar/google/callbackretorno de Google (sin sesión; la identidad va en el state)
GET/v1/calendar/connectionscuentas conectadas (sin tokens)
POST/v1/calendar/connections/:id/syncforzar una pasada ahora
DELETE/v1/calendar/connections/:iddesconectar y revocar en Google

Requiere GOOGLE_CALENDAR_CLIENT_ID y GOOGLE_CALENDAR_CLIENT_SECRET en el servidor. Sin ellas la función queda apagada y el panel lo dice: la suscripción .ics sigue funcionando igual.

07 Seguimiento de envíos (para tu web)

El mostrador de «¿dónde va mi paquete?» hacia tu web. Devuelve el recorrido completo por paquete (6 fases con estado, fecha y lugar) y los datos del viaje (buque, ruta y ETA). Server-to-server con la clave de integración (X-Api-Key: trk_live_…); solo lectura.

GET/public/tracking?ref=<HBL o código de bulto>un paquete → { parcel }
GET/public/tracking/customer?code=<cardCode>todos los envíos de un cliente → { shipments[] }
curl "https://ritualerp.ch/public/tracking?ref=AIN26000002" \
  -H "X-Api-Key: trk_live_…"
  → {
    "parcel": {
      "trackingCode": "0313950174026",     // nº del cliente (13 díg.): el que teclea en "Seguir"
      "reference": "AIN26000002",        // HBL si lo tiene; si no, el código del bulto
      "milestone": "EN_TRANSITO",        // la fase actual (ver tabla)
      "recipient": null,                   // OCULTO en público; visible en el portal (login)
      "destination": "Centro Habana, La Habana",
      "weightKg": 12.4,
      "deliveredAt": null,
      "journey": [                          // EL RECORRIDO: las 6 fases, siempre en orden
        { "phase":"RECIBIDO",   "label":"Recibido",    "state":"done",    "at":"2026-07-20T…Z", "location":"Zúrich" },
        { "phase":"PREPARADO",  "label":"Preparado",   "state":"done",    "at":"2026-07-22T…Z", "location":null },
        { "phase":"EN_TRANSITO","label":"En tránsito", "state":"current", "at":"2026-07-25T…Z", "location":"Rotterdam" },
        { "phase":"EN_ADUANA",  "label":"En aduana",   "state":"pending", "at":null, "location":null },
        { "phase":"EN_REPARTO", "label":"En reparto",  "state":"pending", "at":null, "location":null },
        { "phase":"ENTREGADO",  "label":"Entregado",   "state":"pending", "at":null, "location":null }
      ],
      "transit": {                        // el viaje que lo lleva (null si aún no está asignado)
        "mode":"SEA", "carrier":"MSC", "vessel":"MSC Ambra", "voyage":"021W",
        "from":"Rotterdam", "to":"Mariel",
        "departedAt":"2026-07-25T…Z", "etaAt":"2026-08-12T…Z"
      },
      "alert": null,                     // { type:"INCIDENCIA"|"DEVUELTO", at, note } si hay desvío
      "timeline": [ … ],                   // hitos ocurridos (compat.); usa "journey" para pintar el progreso
      "packingList": null            // en público, items ocultos; el portal (login) los muestra
    }
  }

Público vs. privado: el tracking público oculta el recipient (nombre) y los items de la lista — cualquiera con el número no debería verlos. El mismo parcel en el portal con login (GET /portal/shipments) sí los incluye. Búscalo por trackingCode (nº del cliente), HBL o código de bulto.

Las 6 fases (phase / milestone)

CódigoSignifica
RECIBIDOEntró en el almacén de origen.
PREPARADOEmpaquetado / etiquetado / en manifiesto, listo para salir.
EN_TRANSITOCargado y de camino (el barco/avión salió). Mira transit.
EN_ADUANALlegó al país de destino; en trámite aduanal.
EN_REPARTOSalió a la dirección del destinatario.
ENTREGADOEntregado (con acuse). deliveredAt lleva la fecha.

Cada paso del journey trae state: done (ya pasó), current (donde está ahora) o pending (falta) — pinta una barra de progreso sin lógica extra. Si el paquete tiene una incidencia o fue devuelto, milestone vale INCIDENCIA/DEVUELTO y alert trae el motivo; el journey se congela en la última fase alcanzada.

Mismo objeto en el portal

El portal de clientes (§08) devuelve exactamente este objeto parcel dentro de cada envío en GET /portal/shipments. Documenta las fases una vez y reutilízalas en ambas vistas.

08 Portal de clientes (para tu web)

Que tus clientes sigan sus datos desde tu web sin que tú tengas que reexponer nada — con la propiedad forzada en Ritual, no en tu código.

Flujo: tu web autentica al cliente (tu login). Tu servidor, con la clave DEDICADA del portal (psk_live_…, separada de la de tracking y atable a IP), pide una sesión para ese cliente; Ritual devuelve un token corto atado a él. Recomendado (proxy): guarda el token en tu backend (cookie httpOnly) y reenvía las llamadas a /portal/* — así el token no toca el navegador y no hace falta CORS.

# 1) Tu servidor (clave dedicada del portal) pide sesión para un cliente ya autenticado
POST /public/portal/session      X-Api-Key: psk_live_…
     { "customerId": "<id del cliente en Ritual>" }
  → { "token": "cst_…", "expiresAt": "…" }        # 60 min · 403 si la IP no está en la allowlist

# 2) Las llamadas a /portal/* con ese token (Authorization: Bearer cst_…) — vía tu backend (proxy)
GET/portal/shipmentssus envíos + línea de tiempo
GET/portal/mesu ficha · ver (GET) · editar sus datos (PATCH)
GET/portal/facturas · /portal/facturas/:id/pdfsus facturas
GET/portal/recibos · /portal/recibos/:id/pdfsus recibos de mostrador (ventas POS) · PDF
GET/portal/destinatarios · /portal/remitentesver (GET) · crear (POST) · editar (PATCH) · quitar de su lista (DELETE)
GET/portal/direccionessus direcciones de entrega · ver (GET) · crear (POST) · editar (PATCH) · archivar (DELETE)
GET/portal/packing-listssus listas de empaque · ver (GET) · crear (POST) · borrar no vinculada (DELETE)
GET/portal/packing-lists/:code/pdfel PDF con código de barras de una lista suya
GET/portal/envios/:ref/hbl.pdfel HBL de un envío suyo

Al crear una lista (POST /portal/packing-lists) el customerId lo pone la sesión (nunca el cuerpo) y Ritual genera el código PKL-…; devuelve la lista con su código para descargar el PDF. Si el remitente/destinatario se referencia por { id }, debe ser del propio cliente. Una lista solo se puede borrar mientras siga en SUBMITTED (aún no vinculada a un envío).

Editar su ficha (PATCH /portal/me). El cliente cambia SUS datos de contacto y dirección postal: salutation, name, firstName, lastName, email, phone, language (de|fr|it|en|es), street, postalCode, city, canton, country. Es un PATCH parcial (los campos que no mandas no se tocan). Quedan FUERA a propósito los que no le corresponde tocar: código de tarjeta, saldo/nivel de fidelidad, consentimientos y notas internas. Si el email ya lo usa otra cuenta del centro devuelve 422. Responde la ficha ya actualizada, igual que GET /portal/me.

Destinatarios y remitentes: favoritos del cliente. La lista que ve el cliente (GET /portal/destinatarios · /portal/remitentes) son SUS favoritos. Un destinatario/remitente pasa a ser favorito de un cliente de dos formas: cuando el propio cliente lo da de alta (POST), o cuando el mostrador lo USA en una recepción suya (aunque venga del pool compartido del centro). Así, tras un envío, ese contacto aparece solo en su ficha y en su portal. El cliente puede EDITAR (PATCH) cualquiera de su lista, no solo los que creó: si el contacto es exclusivamente suyo se edita en el sitio; si lo comparten otros clientes, Ritual lo CLONA a una copia propia del cliente (copy-on-write) y edita la copia, para no cambiarle el dato a terceros. El identificador puede cambiar tras esa primera edición; vuelve a leer la lista. Con DELETE el cliente lo QUITA de su lista (se borra su favorito, no la ficha del pool, que puede ser de otro o estar en envíos ya emitidos). En destinatarios, POST/PATCH aceptan además la foto del carné (fotoFrontalUrl/fotoTraseraUrl, dataURL de imagen); Ritual la cifra en reposo y el listado no la devuelve (solo hasFotoFrontal/hasFotoTrasera).

Nunca en el navegador

La clave psk_live_… es de servidor: jamás en el navegador, y conviene atarla a las IPs de tu servidor. En el modo proxy (recomendado) el token cst_… tampoco toca el navegador; si vas a navegador-directo, el cst_… puede vivir en el cliente (corto, de un cliente, sin acceso a administración) pero asume el riesgo XSS.

09 Calculadora de envíos (para tu web)

Un cotizador en tu web alimentado por el MISMO motor de precios que el mostrador: tarifas, precios por zona, envases, servicios y arancel. Lo que edites en el ERP se refleja al instante.

Server-to-server con la clave de integración (X-Api-Key: trk_live_…), igual que el tracking. Solo lectura; no escribe nada. El precio es orientativo (el arancel es provisional y el tipo de cambio es el del centro).

GET/public/paqueteria/opcionesdestinos con tarifa, modos, envases (con su code) y servicios
POST/public/paqueteria/cotizarprecio de un envío, con desglose
# país destino (ISO3) + modo + paquetes (+ aduana) + servicios
curl -X POST https://ritualerp.ch/public/paqueteria/cotizar \
  -H "X-Api-Key: trk_live_…" -H "Content-Type: application/json" \
  -d '{
    "pais": "CUB",
    "modo": "maritimo",
    "paquetes": [
      { "tipo":"peso", "pesoKg":12,
        "articulos":[ { "descripcion":"Ropa", "pesoKg":12, "valorCup":100 } ] },
      { "tipo":"envase", "envase":"<code del envase>" }
    ],
    "servicios": [ { "id":"<id servicio>", "cantidad":1 } ]
  }'
  → { "zona":"CU", "tarifa":"Temporada 4", "total":"146.50",
      "aduana":{ "regimen":"Aduana de Cuba", "modo":"WEIGHT", "moneda":"CUP" },
      "paquetes":[ { "lineas":[ … ], "subtotal":"…" } ], "aviso":"Precio orientativo…" }

El arancel depende del destino. Cada zona tiene su régimen aduanal (la fórmula del arancel, configurable en el ERP): por peso, por porcentaje del valor declarado, o ninguno. El bloque aduana dice cuál se aplicó y en qué moneda; si llega null, ese destino no prepaga arancel en origen —lo liquida la aduana de destino con el destinatario— y la cotización no lleva línea de aduana. Los articulos se declaran igual: la declaración de mercancía es obligatoria en todos los destinos.

Cómo montarla en tu web

La clave es de servidor, así que el patrón es: un pequeño proxy en tu backend guarda la clave y reenvía a Ritual; tu frontend llama a tu proxy (nunca a Ritual directo).

1) Proxy en tu servidor — aquí vive la clave:

// Node / Express — la clave NUNCA sale de tu servidor
const BASE = 'https://ritualerp.ch', KEY = process.env.RITUAL_KEY; // trk_live_…
const H = { 'X-Api-Key': KEY, 'Content-Type': 'application/json' };
app.get('/api/envio/opciones', async (_q, res) => {
  const r = await fetch(BASE + '/public/paqueteria/opciones', { headers: H });
  res.status(r.status).json(await r.json());
});
app.post('/api/envio/cotizar', async (req, res) => {
  const r = await fetch(BASE + '/public/paqueteria/cotizar',
    { method:'POST', headers: H, body: JSON.stringify(req.body) });
  res.status(r.status).json(await r.json());
});

# …o en PHP (Hostinger): cotizar.php
<?php
$ch = curl_init('https://ritualerp.ch/public/paqueteria/cotizar');
curl_setopt_array($ch, [ CURLOPT_POST=>true, CURLOPT_RETURNTRANSFER=>true,
  CURLOPT_HTTPHEADER=>['X-Api-Key: '.getenv('RITUAL_KEY'), 'Content-Type: application/json'],
  CURLOPT_POSTFIELDS=>file_get_contents('php://input') ]);
echo curl_exec($ch);

2) La calculadora en tu página — llama a TU proxy:

<form id="calc">
  <select name="pais"></select> <select name="modo"></select>
  <input name="pesoKg" type="number" placeholder="Peso (kg)">
  <button>Calcular</button>
</form>
<p id="res"></p>
<script>
// 1) poblar desplegables desde /opciones
fetch('/api/envio/opciones').then(r => r.json()).then(o => {
  calc.pais.innerHTML = o.zonas.map(z => `<option>${z}</option>`).join('');
  calc.modo.innerHTML = o.modos.map(m => `<option value="${m.code}">${m.code}</option>`).join('');
  // o.envases (code+nombre) y o.servicios (id+nombre) para más campos
});
// 2) cotizar al enviar
calc.addEventListener('submit', async e => {
  e.preventDefault();
  const body = { pais: calc.pais.value, modo: calc.modo.value,
    paquetes: [{ tipo:'peso', pesoKg: Number(calc.pesoKg.value) }] };
  const q = await (await fetch('/api/envio/cotizar',
    { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) })).json();
  res.textContent = q.total ? `Total: ${q.total} CHF — ${q.aviso}` : (q.error || 'Error');
});
</script>

Amplía a gusto: envase (tipo:"envase", envase:"<code>"), volumen (tipo:"volumen" + largoCm/anchoCm/altoCm), aduana (articulos:[…]) y servicios (servicios:[{id,cantidad}]). Los code de envase y los id de servicio salen de /opciones.

Cómo se calcula

El pais (ISO3) se resuelve a zona; con modo se elige la tarifa, y con la zona, el régimen aduanal. Cada paquete es peso (kg×tarifa), volumen (m³×tarifa) o envase (precio del envase, con su precio por zona si lo hay); fleteManualChf fija el flete a mano. Los articulos suman la aduana si la zona tiene régimen. GET /opciones te da los code de envases y los id de servicios para tus desplegables.

Nunca en el navegador

La clave trk_live_… es de servidor: llama al cotizador desde tu backend y sirve el resultado a tu web. No la expongas en el frontend.

10 Construir un módulo dentro del ERP

Un módulo es una unidad vendible (agenda, facturas, paquetería…) con sus rutas, roles, plan y scopes. El catálogo vive en un solo sitio y todo lo demás se deriva de él.

Añadir un módulo con clave <clave>:

#DóndeQué
1entitlements.ts · MODULE_CATALOGLa clave y su grupo (operación / recursos / finanzas).
2MODULE_BY_PREFIXQué rutas /v1/<seg> cobra el módulo. Sin esto la ruta es gratis (no gateada).
3DEFAULT_ROLE_ACCESSQué roles lo ven por defecto.
4MODULE_REQUIRESSolo si necesita otro módulo para funcionar.
5PLAN_TIERSEn qué peldaños de plan se vende.
6Panel: permissions.ts + Dashboard.tsxMODULE_ACCESS / MODULE_MATRIX y la pestaña (Tab) + render.
7i18n nav.<clave>Etiqueta en es / de / fr / it.
8Tablas nuevas con tenantIdAñádelas a TENANT_TABLES y aplícales RLS+GRANT en una migración.

Los scopes de la API (<clave>:read / <clave>:write) se generan solos desde el catálogo — no hay listas paralelas. Hay un generador (tsx scripts/new-module.ts) que escribe los pasos 1–3 y 6–7; si te saltas alguno, plan-tiers.spec (1–3) o rls-coverage.spec (8) fallan a propósito.

Gating real

Una petición autenticada pasa tres puertas: rol (¿este usuario/clave puede?), plan/suscripción (¿el centro tiene el módulo contratado y al día?) y propiedad/RLS (¿es de su centro?). Un módulo bien dado de alta las respeta sin que escribas el chequeo en cada handler.

11 Checklist de seguridad senior

Antes de dar por bueno un endpoint, un módulo o una integración.

AislamientoToda tabla con tenantId → RLS + GRANT + TENANT_TABLES. Todo acceso vía withTenant. Nada de where tenantId a pelo como única barrera.
DineroIdempotency-Key en toda mutación de dinero. El servidor recalcula importes/impuestos; nunca confía en el cliente. Índices únicos que hagan imposible el doble cobro/devolución.
SecretosHash (SHA-256) + comparación en tiempo constante. Se enseñan una vez. Nunca en logs ni en la URL. Caducidad y revocación.
AutorizaciónRol × scope × propiedad en cada ruta. Valida el cuerpo con esquema. Las rutas de administración jamás son alcanzables por clave de API.
EnumeraciónNo expongas identificadores secuenciales sin atar la consulta a su dueño (el token del portal ata la propiedad; por eso no hay enumeración).
WebhooksFirma HMAC verificada sobre el cuerpo crudo + anti-repetición (timestamp) + idempotencia por id de evento. URL solo https, anti-SSRF.
TransporteTLS + HSTS. Rate-limit por IP. Responde rápido; el trabajo pesado, a una cola.
Ritual ERP · Manual para desarrolladores. El OpenAPI (/v1/developer/openapi.json) y el panel de Desarrolladores son la fuente de la verdad viva; este manual explica el porqué y las reglas que no salen en el contrato.

Ritual ERP · un producto de MiFirma