Servicio de autenticación y autorización. Integra Keycloak (OIDC + Google Identity, ADR-0007/0055) para autenticación. Autorización vía Keycloak Realm Roles validados localmente en PermissionGuard (sin PDP externo, ADR-0055). CASL se mantiene solo en frontend para checks UI declarativos.
- ✅Esquema DB y migraciones (ADR-0010 + external_identities)
- ✅TrustTokenSigner (ADR-0026 §1, contraparte de svc-audit)
- ✅Health probes + métricas Prometheus
- ✅JwtVerifier con JWKS Keycloak + errores tipados
- ✅ForwardAuth endpoint POST|GET /v1/auth/verify (ADR-0008)
- ✅Cache hardening completo: jitter + single-flight + SWR + circuit-breaker + deny-list + TTL superadmin (ADR-0008 §1-6)
- ✅Endpoint admin POST /v1/admin/sessions/:userId/revoke-now
- ✅Migración a PermissionGuard local (ADR-0055 PR-I4): elimina /v1/authorize HTTP + subsistema CASL completo
- ✅Audit emission a svc-audit por NATS JetStream (reyva.identity.access)
- ✅Health probes completos (Postgres/Redis/NATS) con detail per-dep + métricas
- ✅Graceful shutdown hooks: NATS drain, Redis quit, Postgres pool end (rolling deploys K8s/Cloud Run)
- ✅Hardening env hardcoded: DATABASE_URL obligatoria sin fallback dev (production-ready, sin secretos en code)
- ✅Tests integración + e2e (156 con docker-compose: postgres+keycloak+redis+nats)
- ✅Rate limiting endpoints públicos (@nestjs/throttler + Fastify trustProxy + métrica Prom identity_rate_limited_total)
- ✅Trust token con exp + nonce anti-replay (ADR-0026)
- ✅Audit event decision_source diferencia fail-open de allow real (auditoría 2026-05-12 alto #7 + #12)
- ✅POST /v1/authorize endpoint PDP central (ADR-0009)
- ✅Catálogo de permisos Keycloak Realm Roles (ADR-0055 PR-I1): 32 atómicos + 8 Composite Roles en @reyva/shared-contracts + check-permissions-sync CI
- ✅Endpoints HTTP proxy Keycloak Admin API (ADR-0055 PR-I4): GET/PUT/DELETE /v1/admin/keycloak/{roles,users/:id/role}
- ✅Eliminación /v1/authorize (ADR-0055 PR-I4): validación pasó a PermissionGuard local en cada svc-*, 0 HTTP calls a auth por request
- ✅JwtRoleGuard any-of (JWT Keycloak admin OR trust token S2S) + GET catalog + GET users search (ADR-0054 PR-H5 C1)
- ✅Lockout-prevention DELETE /v1/permissions/roles last_admin_role_protected (ADR-0054 PR-H5 A)
- ✅Métrica Prometheus identity_jwt_role_guard_total (PR-H5 closeout)
- ✅GET /v1/admin/keycloak/users/:userId/roles (ADR-0055 PR-I4 — consumido por UserRolesAssigner)
- ✅Motor de permisos consolidado en PermissionGuard local (ADR-0055 PR-I8) + cleanup del sidecar previo (PR-H7)
- ✅Cierre auditoría 2026-05-12 (altos): CWE-117 log sanitization + bloqueo Unicode confusables en forwardedUri
- ✅Application layer migrado a Result<T, E> (errores tipados, sin throw en use-cases/repos)
- ✅Lazy provisioning Google OIDC (ADR-0011) — MVP: schema + use-case + FOR UPDATE + dominio whitelist + Workspace port stub
- ✅Migración ADMIN_API_TOKEN fallback → trust token vía Traefik ForwardAuth (escape hatch IDENTITY_DEV_AUTH_BYPASS=1 para dev local)
- ✅Reverse-proxy GET /v1/admin/audit-events a svc-audit (firma trust token con principal admin, consumido por web-admin P12)
- ✅Endpoint admin GET /v1/admin/business-subjects con paginación + filtro subject_kind + enrichment teléfono vía RPC NATS a svc-legacy-bridge (P5)
- ✅Endpoints admin detalle/PATCH/CRUD atributos business_subjects (P6) + publisher reyva.identity.business_subject_changed + repo user_attributes con soft-delete versionado
- ✅Endpoints admin invitaciones (LIST/CREATE/EXPIRE/REFRESH) (P7+P8) + publisher reyva.identity.invitation_changed sobre pending_user_provisioning
- ✅El teléfono del pre-registro se guarda (migración 0016): columna telefono + CHECK 10 díg + UNIQUE parcial sobre los no consumidos, obligatorio en POST /v1/admin/invitations (400/409 distinguibles), propagado a users.telefono al materializar, y enmascarado en lectura
- ✅Reverse-proxy admin chat-sessions + bot-settings a svc-conversation (P3/P4/P11) — SvcConversationClient firma trust token con principal admin
- ✅KeycloakAdminClient con cache de admin token + 5 métodos REST (ADR-0055 PR-I4)
- ✅AdminRolesController: proxy Keycloak para gestión de roles desde apps/web (ADR-0055 PR-I4)
- ✅PermissionChangedPublisher: emite reyva.identity.permission_changed tras assign/revoke (ADR-0055 FU-3)
- ✅POST /v1/admin/business-subjects/:userId/reveal-phone + PiiRevealedPublisher (audit P0-3 S2.4 — granularidad fina LFPDPPP ADR-0027 §6)
- ✅Catálogo permisos: agregado Identidad.Contacto.Revelar (admin_security/admin_plataforma/superadmin) + sync Keycloak realm-reyva.json
- ✅Restauración GET /v1/permissions/users (ADR-0055 PR-I4 follow-up): lookup admin read-only
- ✅El padrón sale de identity: la proyección de vendedores y subdistribuidores pasa a svc-distributors, y svc-identity se queda sólo con lo que autentica
- ✅S4.6: TrustToken guards (JwtRoleGuard + TrustTokenOnlyGuard) deny-by-default sin IDENTITY_TRUST_HMAC_SECRET — opt-in dev local via IDENTITY_TRUST_HMAC_BYPASS_DEV=1 (audit P1-3, cierra ventana staging/preview auth-less)
- ✅S4.6: JwtRoleGuard valida x-user-id == claims.sub pre-rewrite (audit P1-7 v2) — bloquea forge attempt antes del rewrite a users.id interno, cierra impersonation forense LFPDPPP §3.1 ARCO en TODOS los endpoints admin (audit-events, business-subjects, invitations, conversation, sessions). Métrica deny_header_user_mismatch + log forensic con email_hash
- ✅PR-A empleado-recarga-bot: columna identity.employees.telefono (+ índice parcial) + endpoint admin GET/PATCH /v1/admin/employees/:userId (normaliza 10 díg MX vía normalizeMxPhone, PII masking en respuesta/logs, audit-on-write reyva.identity.employee_changed con hashes). El audit-on-write quedó COMPLETO recién el 2026-08-11: el publisher existía desde PR-A pero nadie consumía el evento —expiraba en el stream sin llegar al audit trail WORM—; lo cierra EmployeeChangedConsumer de svc-audit (resource_kind=identity_employee), medido 0→N filas en audit.audit_events
- ✅RPC NATS resolve_for_recharge (PR-B empleado-recarga-bot): resuelve teléfono→empleado activo + evalúa Recargas.Recarga.Crear EN identity (ADR-0054; entonces vía la tabla fija CompositeRoleDefinitions, retirada el 2026-10-01); trust token del caller (ADR-0026), fail-closed (DB caída→unavailable, nunca found:false), cache negativo del miss. Consumido por el gate del bot (PR-C)
- ✅Endpoint HTTP privado POST /internal/employees/resolve-for-recharge (feature 014 PR-1, #882): reemplaza el transporte del RPC NATS resolve_for_recharge por HTTP; ingress interno + allowed_invokers=[sa-svc-conversation] + ConversationTrustTokenGuard (reusa verifyTrustToken per-caller, ADR-0026) + firma OIDC de GCP. Misma lógica de resolución fail-closed; el RPC NATS se retira en PR-3
- ✅Grant read-only reyva_ro sobre schema identity (feature 014, migración 0011): USAGE+SELECT para el Job db-query (ADR-0062) que audita SC-008 (empleados resolubles por tabla); patrón canónico de 0008_grant_reyva_ro_conversation, guard IF EXISTS del rol
- ✅Fix C11 (audit 2026-07-29): GET/PATCH /v1/admin/employees/:userId exigen el permiso atómico Identidad.Empleado.{Ver,Editar} in-handler, no solo el allowlist plano del JwtRoleGuard. Ese allowlist incluye monitor_operativo (rol read-only de monitoreo, ADR-0057) que podía leer el padrón de empleados y MUTAR el teléfono que dirige las recargas del bot, quedando registrado en el audit como acción administrativa legítima. 7 tests adversariales (403 sin permiso, fail-closed sin claims, permisos no fungibles) verificados en rojo al revertir el fix
- ✅El PADRÓN sale de identity: se retiran los 2 consumers de snapshot, el repo de sync, los endpoints admin de business_subjects y la tabla identity.business_subjects. Vendedores y subdistribuidores no autentican ni autorizan (medido: 56.364 filas con CERO roles contra 9 empleados con rol) e inflaban 6.000× una tabla del camino crítico del bot. Los recibe svc-distributors, directo del puente. Identity conserva empleados, roles, atributos, permisos y autenticación. Rotura declarada: las pantallas /admin/users quedan sin backend
- ✅El contrato dice el veredicto (Ola 3, A2/A3): POST /api/v1/identity/actors/resolve reemplaza /internal/employees/resolve-for-recharge — responde {autorizado:true} o {autorizado:false, motivo} en vez de dos booleanos sin razón (found/hasRechargePermission). El WHERE deja de colapsar inactive/suspended a "sin fila": ahora los 4 motivos (no_es_empleado, empleado_inactivo, empleado_suspendido, sin_permiso_de_recarga) viajan con 200; 503 solo cuando identity no puede opinar (base_no_responde). Contrato compartido identity-resolve-employee.contract.ts (@reyva/shared-contracts); el emisor parsea su propia salida contra el schema antes de responder (patrón A28 de distributors). rpc.dto.ts se borró entero (0 consumidores externos medidos) junto con el directorio interfaces/nats/. svc-conversation NO se tocó a propósito: sigue declarando su propio schema local, así que su safeParse rechaza el shape nuevo y degrada fail-closed en silencio hasta su propio turno
- ✅La base olvida al padrón que ya no vive (Ola 5, A9): users_kind_chk se acota a kind = "employee" (NOT VALID — bloquea filas nuevas, no revalida las preservadas). Se borran 56.367 filas identity.users kind=business_subject sin FKs entrantes (user_roles.granted_by, user_attributes.set_by, pending_user_provisioning.imported_by/consumed_user_id); las referenciadas se conservan como huérfano benigno, mismo criterio que 0013. El flujo de invitaciones ya no admite business_subject (cerrado por PR #993): esta ola verifica que no quedó ningún camino de creación abierto y cierra el CHECK que permitía el estado imposible
- ✅El teléfono vive junto al usuario y la tabla satélite desaparece (migración 0017): identity.employees se retira e identity.users gana telefono NOT NULL + users_telefono_unique_idx + CHECK de 10 dígitos. El alta de un usuario de plataforma es correo + teléfono + rol, y pasa a ser UN INSERT en vez de dos: mientras el teléfono vivió en una satélite OPCIONAL, un usuario podía existir sin fila ahí —con panel y FUERA del gate de recarga, sin que nada lo señalara—, y ése era exactamente el estado del único usuario de dev (users=1, employees=0). Con NOT NULL ese estado deja de ser representable, no queda como validación saltable. hire_date y department se van con la tabla (cero lectores y cero escritores de producción, medido). El gate de recarga resuelve ahora contra users sin JOIN, conservando el UNIQUE que impide autorizar a recargar dinero real al usuario equivocado (control money-path de 0014) y su detector de integridad. Un pre-registro anterior a la 0016 (sin teléfono) ya NO materializa en silencio: rechazo explícito pending_telefono_missing que nombra la invitación a corregir y deja el pre-registro sin consumir; upsertFromJwt deja de crear usuarios (el JWT no trae teléfono) y devuelve user_not_provisioned. 5 garantías con test adversarial verificado en ROJO al revertir cada fix (NOT NULL, UNIQUE, CHECK de formato, el nombre del índice hardcodeado que degradaba el 409 a 500, y el rechazo del pending sin teléfono), contra Postgres real — no mock. Se documentó en ADR-0011 §Addendum 2026-09-18 por qué el teléfono NO va en Keycloak: medido contra Keycloak 26.6.1, crear un segundo usuario con el mismo teléfono devuelve 201 Created y ninguno de sus 13 validadores es de unicidad, así que el constraint duro del money-path no puede bajar a una validación de aplicación
- ✅El contrato NATS deja de nombrar al "empleado": el publisher del audit-on-write del teléfono pasa a reyva.identity.user_telefono_changed con action user.telefono_changed (antes employee_changed/employee.telefono_changed, que afirmaban una entidad employee que la plataforma NO administra — administra usuarios; "empleado" era vocabulario de negocio, y su tabla satélite se retiró en la 0017). El nombre dice QUÉ cambió, no de qué entidad. NO se reusó reyva.identity.user_changed aunque el teléfono sea campo del usuario: ese subject tiene durable vivo y ajeno (svc-notifications-user-changed, invalidación del cache de opt-out), y publicarle ahí le entregaría eventos de una forma que no espera — anclado con test. El payload NO cambió (ningún campo era columna de employees; el teléfono sigue viajando sólo como HMAC, nunca en claro). Métrica renombrada a user_telefono_changed_events_emitted_total (cero referencias en infra/, medido). Validado en runtime, no sólo en CI: el use case real corrido contra el stack de dev cambió el teléfono en identity.users y la fila llegó a la hash-chain WORM como resource_kind=identity_user vía el durable nuevo
- ✅La contraseña sólo la conoce el usuario (ADR-0069): el alta y "Generar contraseña nueva" (POST /v1/admin/users/:userId/reset-password, permiso Identidad.Credencial.Resetear) generan una contraseña definitiva (CSPRNG 192 bits, sin UPDATE_PASSWORD) y svc-identity se la envía por correo directo con el mismo adaptador Gmail del drain, sin pasar por NATS, BullMQ ni ninguna tabla. Ni la respuesta HTTP, ni la pantalla, ni los logs, ni el asiento de auditoría la llevan. Con el envío apagado, el alta y generar responden 503 correo_no_disponible sin tocar Keycloak. Si el correo falla al enviarse, el alta deja creado al usuario y lo dice (correo: no_enviado) y generar responde 502 correo_no_enviado; el reintento es generar otra. Generar una nueva fija la credencial y cierra sesiones en todas sus cuentas Keycloak, sólo aplica a empleados activos, falla cerrado si Redis no responde y nadie la genera para sí mismo. Con EMAIL_DWD_SERVICE_ACCOUNT firma la DWD como sa-svc-notifications
- ✅El alta de un usuario operativo que falla ya no deja al admin sin salida: cuando la materialización en Postgres falla, el alta BORRA la cuenta Keycloak que acababa de crear (compensación, KeycloakAdminClient.deleteUser). El defecto no era el huérfano en sí sino el callejón: alta con teléfono repetido → 409 "ese teléfono ya es de otro usuario" (mensaje correcto) → el admin corrige SÓLO el teléfono, que es justo lo que el mensaje le pide → el reintento fallaba con "ya existe una cuenta con ese email" por un usuario que NUNCA se creó, y desde la UI no había salida. Medido: Keycloak con enabled:true mientras Postgres tenía 0 filas. Se eligió compensar y NO invertir el orden porque el schema lo impide: external_identities.sub es NOT NULL y mitad de la PK (provider, sub), así que no existe "fila reservada con sub pendiente" sin una migración; y NO se agregó un chequeo previo del teléfono porque es TOCTOU y no cierra la carrera. El UNIQUE de money-path (users_telefono_unique_idx, 0014/0017) sigue siendo constraint DURO de la base —la compensación actúa del lado de Keycloak, después de que el índice ya dictó su veredicto— y un test lo ancla con un INSERT directo que lo saltea y sigue rebotando. Excepción deliberada: external_identity_conflict NO se compensa (ahí el sub ya era de otro usuario materializado; borrarlo lo dejaría sin login). Si el borrado compensatorio falla, el error que ve el admin sigue siendo el original y el log nombra el sub para el runbook — el estado no puede quedar peor que antes. 4 casos contra Postgres real (testcontainer) con el repositorio REAL, no mock, verificados en ROJO al revertir el fix (2 de 4 fallan, el primero con la cuenta huérfana que quedó); validado además en runtime contra el stack de dev reproduciendo los 4 pasos
- ✅El pre-registro se retira y el alta queda DIRECTA (migracion 0018): se dropea identity.pending_user_provisioning y se van los 4 endpoints admin de invitaciones (LIST/CREATE/EXPIRE/REFRESH), el lazy provisioning con Google de ADR-0011, el puerto GoogleWorkspaceDirectory con su stub, los 2 repos, el publisher NATS y las 2 pantallas del panel. Orden del dueno: "aqui es directamente registro y ya, se da de alta ... y estos ya entraran a la plataforma desde el momento que los den de alta". El alta es correo + telefono + rol via POST /v1/admin/employees/operational, que ya existia, esta probado y exige el MISMO permiso (Identidad.Empleado.Crear) — no se agrego ninguno: Identidad.Invitacion.* nunca existio. El cambio estructural: autenticarse ya NO crea nada — un sub sin fila en identity devuelve principal_not_provisioned en vez de auto-crear el usuario desde un pre-registro, asi que el alta es un acto administrativo explicito y no un efecto secundario de conseguir autenticarse, con test que falla en rojo si alguien reintroduce el auto-create. La tabla se dropea y no se archiva porque se midio: 28 filas, 0 consumidas, 0 consumed_user_id distintos y CERO FKs entrantes contra el catalogo — ninguna fila nombra a un usuario real (prod y staging se miden aparte ANTES de aplicar: si alla hay filas consumidas, esas SI son trazabilidad y que hacer con ellas lo decide el dueno con LFPDPPP, por eso la migracion no codifica ese branching). De paso cierra deuda: el trinquete de subjects huerfanos baja 5 a 4 retirando el EMISOR de reyva.identity.invitation_changed (0 consumidores, 0 mensajes medidos, sin ventana de drenado), env-baseline pierde IDENTITY_PROVISIONING_ENABLED e IDENTITY_EMPLOYEE_DOMAINS (nunca seteadas en ningun entorno: el flujo jamas corrio en produccion) y sale DEFAULT_REFRESH_DAYS del catalogo de numeros de negocio, que seguia con authorized_by PENDIENTE. Se arreglo ademas un mensaje de codigo VIVO que mandaba al operador a POST /v1/admin/invitations, el endpoint que este trabajo borra
- ✅Una sola forma de autorizar (ADR-0055, nota 2026-10-01): el rol sólo agrupa permisos y la plataforma decide por el permiso efectivo. El bot pregunta al realm los permisos efectivos de la persona (role-mappings/realm/composite, caché 60 s, realm caído → 503, nunca un veredicto) en vez de expandir identity.user_roles contra una tabla en código; /v1/auth/verify firma los roles del JWT y el TTL corto de la caché se decide por esos permisos; el alta operativa asigna el rol en Keycloak. Se borraron CompositeRoles/CompositeRoleDefinitions/rolesGrantPermission y el gate check-single-authorization-form (bloqueante, falla cerrado) impide que runtime vuelva a decidir por nombre de rol
- ✅La Admin API de Keycloak tiene plazo: en el camino del bot, token y role-mappings comparten 1 s y las identidades de la persona se consultan a la vez, así que el realm cabe en los 3 s que el bot espera y un realm lento le llega como 503, no como su propio timeout; el resto (panel) corta a 8 s por llamada
- ✅Un usuario, un rol (ADR-0055 §"Un usuario, un rol"): el alta exige el rol (y el permiso de asignar roles), el cambio de rol asigna el nuevo antes de quitar el previo (nunca deja a nadie sin rol), se serializa por usuario y audita lo que cambió aunque falle el registro local, ningún endpoint asigna permisos sueltos y "quitar acceso" deshabilita al usuario (status inactive primero, después Keycloak) en vez de quitarle el rol
- ✅Nadie actúa sobre sí mismo (no cambia su rol, no se quita ni se rehabilita el acceso: 403) y "Rehabilitar" es el inverso exacto de quitar acceso: exige que el usuario tenga rol, no revive sesiones viejas, marca activo al final y comparte turno con quitar acceso (ADR-0055)
- ✅Cada ruta del panel exige su permiso, no sólo "algún permiso": historial (Recargas.Historial.Ver, export con Exportar), vendedores (Distribuidores.Punto.Ver), auditoría (Audit.Registro.Ver), monitor (Monitor.Vivo.Ver / Monitor.Alerta.Ver) y la lista de tipos de acceso (Identidad.Rol.Gestionar o Asignar). Sin el permiso: 403 sin llegar al backend (ADR-0055, nota 2026-10-06)
BackendOla 2svc-recharge
Recargas y Productos Digitales
🔄En construcción
Materializa el saldo con el que se vende cada SIM (AT&T, Movistar, Unefon): REYVA vende SIMs con saldo incluido, no saldo suelto. El monto lo lee del inventario al cobrar — nadie lo elige. Conecta con TurboCarga vía bridge ACL y maneja idempotencia exactly-once para que una SIM se cobre una sola vez.
- ✅Scaffold NestJS + Fastify + health probes + bootstrap (PR-A)
- ✅RechargeProviderPort + MockProvider (ADR-0050 §1)
- ✅Schema DB inicial (recharge_requests, attempts, sim_reservations, outcomes)
- ✅NATS publisher/consumer + stream REYVA_RECHARGE + event RechargeRequested (ADR-0050 §4)
- ✅Saga orchestrator + state machine + ports stub (ADR-0050 §3 PR-1)
- ✅Saga + ports migrados a Result<T, E> (errores tipados, sin throw en orchestrator)
- ✅Compensaciones saga + trail recharge_attempts + handling provider_exception (ADR-0050 §3 PR-2)
- ✅Manejo indeterminate + pending_reconciliation (ADR-0050 §3 PR-3)
- ✅Fix: conciliación escala filas con provider sin QUERY_STATUS capability (ADR-0050 §6, evita filas huérfanas)
- ✅Fix: claimBatch atómico claim-and-mark con lease (audit P1-10, evita doble-claim entre réplicas del cron)
- ✅Lock distribuido Redis anti doble-tap (ADR-0050 §4 PR-4)
- ✅Política reintentos por capability (ADR-0050 §6 PR-5)
- ✅API REST POST /v1/recharges (sin auth, pre-prod) (ADR-0050 §3 PR-6)
- ✅TrustTokenGuard HMAC en endpoint REST (ADR-0026 / PR-F)
- ✅Nonce store anti-replay Redis SET NX EX en TrustTokenGuard (ADR-0026 §Notas P0-2, audit 2026-06-14 P0-2): bloquea replay del mismo token v2 → recarga duplicada; fail-closed ante Redis caído
- ✅Aislamiento del secret HMAC s2s per-caller (ADR-0026 §Notas #31): el verifier solo acepta tokens firmados por svc-conversation (su caller autorizado); comprometer otro servicio NO permite forjar recargas (CRIT-11)
- ✅Authorization via PermissionGuard local con shared-infrastructure (ADR-0055 PR-I5): elimina HTTP call a /v1/authorize
- ✅PermissionGuard con @RequirePermission(Permissions.Recargas.Recarga.Crear) (ADR-0055 PR-I5)
- ✅SLI de éxito de recarga (reyva_recharge_initiated/executed_total) para SLO ≥95% (ADR-0056)
- ✅Tests integración + e2e saga (7 escenarios — scenario 8 audit hash chain diferido a svc-audit integration)
- ⏳Adapter `primary` real Recargaki (BLOQUEADO 2026-05-26: requiere ficha técnica del proveedor — ver claudedocs/solicitud-recargaki-ficha-tecnica-2026-05-26.md)
- ✅HttpVinculacionAdapter consume svc-vinculacion (Ola 2)
- ✅Endpoint admin POST /v1/admin/recharges/:id/dispute (ADR-0050 §7, Ola 2)
- ✅Endpoint admin POST /v1/admin/recharges/:id/refund capability-gated (Ola 2)
- ✅Endpoint admin POST /v1/admin/recharges/:id/reconcile (Ola 2)
- ✅Eventos NATS reyva.recharge.{disputed,refunded,reconciled} (Ola 2)
- ✅Tabla recharge_admin_actions (audit append-only, PR-8 Ola 2)
- ✅El adaptador HTTP contra los endpoints /v1/sims/* de svc-inventory se retira junto con esos endpoints: apartar y soltar una línea viajan hoy por el contrato del apartado, contra el inventario real
- ✅Job conciliación nocturna BullMQ + cruce SIMs↔recargas (ADR-0050 §6, Ola 2)
- ✅AnomalyDetectionService cross-service (sim_sold_no_recharge + recharge_succeeded_no_sim_sold) + métrica recharge_reconciliation_anomalies_total
- ✅Emit reyva.recharge.reconciled tras resoluciones del job nocturno (ADR-0050 §5)
- ✅Persist-first del tRequestID antes del cobro + reconciliación real con queryStatus(tRequestID) + rescate de executing_provider (Incremento 0, ADR-0050/0058)
- ✅Deploy staging
- ✅Deploy producción + canary
- ✅Eliminación AuthorizeClient + PermissionsBootstrapService (-1,222 LOC, ADR-0055 PR-I5)
- ✅Audit P0-3 fix: orden mark-sold ANTES de emit reyva.recharge.completed (workflow S3.7)
- ✅Contract tests s2s svc-recharge ↔ svc-inventory ↔ svc-vinculacion via @reyva/shared-contracts/contracts (workflow S4.1)
- ✅Retención stream REYVA_RECHARGE 7d→365d (prep Ola 3: rebuild read-models history/distributors/reports desde NATS replay, ADR-0052 §3/§313)
- ✅ADR-0058: adaptador SOAP RecargaAqui (GetTRequestID→doT→checkTransaction, fail-fast por env) + reserva pesimista sobre id_linea vía bridge ANTES de cobrar (anti doble-cargo bot↔portal legacy, persistir-luego-actuar)
- ✅Si la operadora no responde, la línea no se da por vinculada y la recarga no se cobra: la verificación se reprograma y se vuelve a intentar. Vale igual para AT&T, Movistar y Unefon, sin excepciones (CRT 2026)
- ✅Detector huérfanas succeeded↔TBL_REYVA_TURBOCARGA (Fase 3 reconciliación nocturna): la decisión es por id_solicitud exacto — dn+ventana fecha_recarga sólo ACOTA el conjunto a traer (RPC list_by_window, índice IDX_TB_fecha_recarga, anti full-scan) + clasificación recuperable/no-recuperable + auto-re-asiento flag-gated DETECTOR_ASIENTO_AUTORESEAT (default off), cierra deuda ADR-0058
- ✅Fix detector huérfanas: la referencia a la tabla externa iba SIN calificar en los subqueries correlacionados (Drizzle renderiza "id" desnudo → scope capture contra recharge_attempts.id → predicado ra.internal_correlation_id = ra.id::text, el attempt contra sí mismo) → provider_correlation_id NULL para TODAS las filas → el detector clasificaba toda recarga succeeded como huérfana. Suite de integración contra Postgres real (el unit test mockeaba el db entero y el SQL nunca se ejecutaba)
- ✅Fix causa raíz de huérfanas AUTOINFLIGIDAS: el provider_correlation_id (tRequestID = id_solicitud del asiento legacy) se persiste ahora en los 2 caminos que lo descartaban — el rescate de reconciliación (lo recibía fresco de queryStatus y lo tiraba) y el path saga clásico execute() sin SPLIT_EXECUTE — más RecargaquiProvider.queryStatus, que no lo exponía y volvía el fix inerte en prod. Sin ese dato la recarga cobrada queda succeeded sin id con que cruzarla contra TBL_REYVA_TURBOCARGA = huérfana irreparable POR CONSTRUCCIÓN. Sin DDL (columna 0005 ya existía, campos del puerto ya opcionales); filtro de audit trail outcome IS NULL intacto
- ✅Kill-switch operacional de emergencia: gate en POST /v1/recharges (503 si activo, cubre bot + monitor-sweep, NO afecta conciliación/refund) + flag Redis recharge:killswitch (caliente, sin re-deploy, fail-safe al leer) + endpoints admin pause/resume/status (superadmin) + métricas + runbook real
- ✅Consumer monitor_recharge_succeeded → correo auto-recarga (enriquece correo del vendedor/sub vía bridge + monto local por recharge_id, emite reyva.notification.requested channel=email, idempotencia correlation_id=monitor_id, fail-silent; deliver=new sobre REYVA_CONVERSATION)
- ✅Hardening prod del consumer de correo (specs/005): retry con backoff solo para db_error en el lookup de monto (3 intentos; not_found no reintenta) + causa raíz encadenada (err.cause) en WARN; desplegado a prod con cpu_always_allocated
- ✅GET /v1/recharges/:id read-only para reconciliación de monitores atascados (fix carrera monitor-recharging §9 P1): status colapsado succeeded/failed/pending (pending_reconciliation/escalated→pending), 404 si no existe, sin efectos ni money-path; grant Recargas.Recarga.Ver a service:svc-conversation vía SERVICE_GRANTS (least-privilege); un failed con cobro capturado (recharge_attempts.outcome=success) reporta pending, nunca failed
- ✅Contrato Zod versionado para POST /v1/recharges (recharge-execute.contract.ts, H1): 6 familias por status HTTP (200/202/409/422/502/500/400) reemplazan 18 HttpException ad-hoc sueltas; el `never` exhaustivo de SagaError sigue protegiendo el mapeo interno kind→HTTP y una segunda capa runtime (safeParse) protege el shape del body; motivo de negativa unificado bajo `reason` (mismo nombre de campo que distributors-*); dos códigos de idempotencia (in_progress/outcome_unknown) dejaron de ser objetos literales sin protección
- ✅Contrato Zod versionado de POST /v1/line-monitors (recharge-monitor-register.contract.ts) con selector por status; reclasificación phone_invalid como 400 de frontera (aLineaCliente() antes de tocar el repositorio, no una negativa de negocio) y retiro del message de prosa en monitor_requires_actor/monitor_unavailable
- ✅La conciliación de apartados trabados cruza por la identidad de la SIM (chip + línea), no por el número solo: el DN se reasigna a un chip nuevo cuando el anterior caduca, así que dos recargas del mismo número pueden ser SIMs distintas y el cobro ajeno declaraba con_cobro sobre una línea trabada — sacándola del radar del humano sin que nada la libere. El asiento del libro legacy trae ahora el chip (CAST(icc AS CHAR), 19 dígitos sin truncar por float64) a través de las cuatro capas de proyección; identidad incompleta NO matchea (fail-closed: cae a revisión humana en vez de afirmar el cobro) y su costo se mide con reserve_timeout_asiento_sin_icc, excluida de la alerta de anomalías por contar asientos y no divergencias
- ✅El trabajo de fondo que quedaba DENTRO del servicio web sale a Cloud Run Jobs: el drenaje de la cola del asiento diferido (recharge-ledger-entries-sweep, cada 5 min) y la conciliación nocturna (recharge-reconciliation, 4am CDMX). El servicio corre con cpu_idle, así que ambos dependían de una CPU estrangulada entre requests — la conciliación vencía a las 4am, justo cuando no hay tráfico del bot que la despierte, y el worker del asiento sólo avanzaba mientras alguien golpeara el servicio por HTTP (cada job de esa cola es una recarga YA COBRADA esperando su asiento contable). El comentario del Terraform afirmaba que min_instances=1 protegía la conciliación: min=1 garantiza que la instancia exista, no que tenga CPU. Se corrige junto con el filtro de la alerta A12, que exigía resource.type=cloud_run_revision y habría quedado MUDO —verde, sin disparar nunca— al pasar el heartbeat a emitirse desde un cloud_run_job
- ✅La conciliación nocturna deja registrado lo que no puede resolver: cada cobro que queda en duda sin salida (el proveedor nunca termina de procesarlo, o se agotaron las consultas) deja una línea en el registro con el folio, el monto y los últimos 4 dígitos de la línea, para cruzarlo a mano contra el portal del proveedor. El detector de recargas cobradas sin registro contable ya no pierde la noche por un tropiezo de conexión al arrancar: reintenta, y si igual no puede, deja registrada la causa real en vez de volcar la consulta — y nunca escribe en el log los datos del cliente que viajaban en ella
- ✅El monitoreo de línea ya no cierra a ciegas un cobro que quedó sin respuesta: conserva el número de pedido que devuelve el cobro aun cuando la respuesta es ambigua (en curso, pendiente de conciliación o error del servidor) y deja que el desenlace real de ese pedido —el de la conciliación incluido— lo cierre. Si no tiene número, busca el pedido por su clave: si existe lo sigue, y si no existe es que no se cobró y vuelve a vigilar para cobrar después. Una línea con un cobro en duda nunca se libera: el monitoreo espera el desenlace del pedido —incluido lo que resuelva la conciliación— y sólo avisa al dueño cuando la plataforma ya no tiene con qué averiguarlo
- ✅Cuando una consulta a la base falla, el registro dice qué falló (código de la base) sin los datos de la consulta ni el texto con que la base cita un valor: el número de la línea y el monto ya no aparecen en los registros de la conciliación nocturna, del cobro, del monitoreo de línea ni de la consulta de estado de una recarga (protección de datos personales, LFPDPPP)
- ✅La alerta de "línea apartada que no se puede vender" sólo llega cuando el problema existe: si el inventario confirma que no llegó a apartar la línea (la base deshizo la escritura), o si al revisarla después la línea quedó disponible, la venta falla sin cobro, el vendedor recibe el aviso de reintentar y no se avisa a nadie
- ✅Recargas puede preguntar si una línea tomada está sólo apartada o ya recargada (con fecha de alta), con la regla única de @reyva/shared/inventario; la pregunta de siempre ("¿sigue tomada?") no cambia
- ✅El monitoreo de línea detecta que la línea ya se recargó por otro medio (portal web, vendedor del sistema viejo, subdistribuidor o el propio bot) y lo cierra como éxito, sin cobrar y sin avisar; sólo cierra si la línea consta RECARGADA (con fecha de alta): una línea sólo apartada sigue esperando con la cadencia normal. Al plazo vence siempre, salvo que la línea ya esté recargada, que cierra como éxito sin el correo de "plazo vencido". Corre un solo barrido a la vez. Revisa cada hora las primeras 48 h, cada 6 h hasta el día 7 y una vez al día hasta el plazo, a un ritmo acotado de consultas a vinculación que deja lugar al bot
- ✅El barrido de monitoreos pregunta la etapa de la línea al puente de a una consulta por vez, para no abrir el breaker que comparte con el bot; si el puente no contesta tres veces seguidas, deja el resto del lote vencido para el próximo tick en vez de reprogramarlo
- ✅Un cobro que se aborta antes de llegar al proveedor (por ejemplo, el padrón no respondió) cierra el pedido sin cobro y libera su clave; el monitoreo vuelve a su próxima revisión normal sin gastar uno de sus intentos
- ✅Cualquier cliente puede preguntar si una línea ya tiene un monitoreo vivo sin darlo de alta (POST /v1/line-monitors/lookup). El bot lo usa para no ofrecer el monitoreo de una línea que ya se está vigilando
- ✅El cierre de los cobros que quedaron trabados antes del proveedor viaja dentro de la imagen del servicio y se corre como un Job puntual de Cloud Run sobre esa imagen, con las mismas credenciales de base del servicio: sin --apply sólo muestra qué haría, y repetirlo no cambia nada
- ✅Si al arrancar la red del contenedor todavía no llega al puente, el barrido de monitoreos repite la primera consulta tras una espera corta y acotada antes de dar el puente por caído, sin gastar la cuota de consultas ni preguntar dos veces por la misma línea
- ✅El alta de monitoreo sólo acepta AT&T, Unefon y Movistar: cualquier otra operadora (Telcel incluida) sale 400 en la frontera. Los monitoreos viejos con otra operadora se siguen leyendo y nunca llegan a consultar la vinculación
- ✅Cuando el cobro de un monitoreo se aborta antes del proveedor, el barrido lo devuelve a vigilar en la misma pasada en vez de contarlo como error; un cobro con desenlace indeterminado ya no se registra como si no se hubiera cobrado
- ✅Una recarga que se detiene antes de pedir folio, con la línea libre (apartado no aplicado, candado caído), le avisa al bot que no hubo cargo: el vendedor recibe "no se realizó ningún cargo, intenta de nuevo" con la línea y el monto, en vez de "es posible que se haya aplicado"
Bot conversacional sobre WhatsApp Business API (Meta). Procesa mensajes entrantes, valida firmas de webhook, y orquesta flujos de venta vía estados. Modular: cada flujo (recargas, soporte, ventas) es un módulo independiente.
- ✅Bootstrap svc (PR-A): scaffold Nest+Fastify + pgSchema conversation + Redis dedupe/rate-limit + NATS publisher reyva.conversation.* + BullMQ queue process-message
- ✅Webhook Meta (PR-B): HMAC fail-closed + anti-replay timestamp+dedupe + phone rate limit + ack optimista <200ms p99 (ADR-0029)
- ✅Domain state machine (PR-C): aggregate FSM + value objects + ports (LineValidator/Vinculacion/Recharge/Sender) + stub adapters (ADR-0015)
- ✅Orchestrator (PR-D): svc-recharge client HMAC trust token + Meta outbound sender HSM templates + ConversationOrchestrator + timeout sweep
- ✅Hardening (PR-E): observabilidad Prometheus + rate limit Traefik + security tests + runbook rotación Meta App Secret
- ⏳Tests integración + e2e end-to-end con Meta Cloud API real
- ✅Endpoints admin chat-sessions + bot-settings (P3/P4/P11) con PermissionGuard + InternalRequestGuard de @reyva/shared-infrastructure (ADR-0055 PR-I6) + tabla bot_settings singleton + publisher reyva.conversation.bot_settings_changed
- ✅docker-compose dev incluye svc-conversation (HMAC compartido con identity para reverse-proxy admin)
- ✅Catálogo plantillas HSM (P9/P10): schema conversation.hsm_templates + endpoints admin GET /templates [+:id] + seed mock 3 plantillas (recarga_exitosa, recarga_fallida, linea_no_vinculada) hasta entrega cliente
- ✅Migración a PermissionGuard + InternalRequestGuard de shared-infrastructure (ADR-0055 PR-I6): elimina TrustTokenGuard local duplicado
- ✅Traefik IP rate limit en docker-compose dev via labels (conv-ratelimit, avg=30/min, burst=50) — audit P0-4 (ADR-0029 §5)
- ✅Respuestas in-session del bot como texto libre, no HSM (Regla #13): bot 100% user-initiated → send_text dentro de ventana 24h
- ✅Monto de recarga server-authoritative desde el inventario (ADR-0015 addendum §A4): elimina el prompt de monto (AWAITING_AMOUNT) — la SIM se vende con su recarga ya definida (modelo venta-de-SIM, SCOPE §3.1/§3.2)
- ✅Acuse de vinculación verificada en el happy path (ADR-0015 addendum §A5.2): el bot confirma "línea verificada y vinculada" antes del prompt de confirmación — cierra el gap de feedback reportado por pilotos (send_text, Regla #13)
- ✅Reconciliación de monitores de auto-recarga atascados en recharging (fix carrera publish-antes-de-respuesta, cierra deuda §9 P1): nak acotado en el consumer + reconciliación read-only del sweep vs GET /v1/recharges/:id de svc-recharge + guard del TTL (STUCK_RECHARGING) — un monitor cuya recarga cobró nunca se marca "no vinculada" por perder el evento NATS
- ⏳Corte diario de pendientes de monitoreo — US1/MVP (feature 015): Job batch espejo del monitor-sweep que lee las líneas que NO terminaron en recarga exitosa, las agrupa por destinatario y encola UN email por vendedor. El correo presenta 2 CATEGORÍAS de negocio, no 3 estados ("No se vinculó" = active+expired, "La recarga falló" = failed). Idempotencia en 2 capas (advisory lock con key propia anti-solape concurrente + tabla daily_digest_sent anti-duplicado secuencial) + aislamiento de fallas por destinatario. El resolver de correo es un stub hasta US2 → NO se envía ningún correo todavía (gate anti-big-bang); done cuando US2 cablee el RPC del bridge y se valide runtime
- ✅Wake-on-demand del puente legacy (feature 010 US1, cierra gap residual #745): ping OIDC fire-and-forget al mensaje entrante + retry único ante no-responders con espera acotada; no-op estructural en prod (WAKE_TARGET_URL ausente) — done al validar runtime en staging (SC-004)
- ✅Mensajes de error diferenciados por clase de causa (feature 010 US3, contrato mensajes-error.md): operadora caída (provider_unavailable/breaker US2 abierto) → texto contractual con nombre de operadora del contexto; transitorio/infra → genérico con reintento; gate SC-006 del catálogo (denylist de términos internos + registro estructural) — done al validar runtime con piloto (quickstart US3)
- ✅Acuse de recibo al validar la línea (#791, addendum ADR-0015 §A5): send_text incondicional antes de check_vinculacion + typing indicator extendido a ese estado — elimina los 15-17s de silencio bajo WSNED degradado que llevaban al vendedor a reenviar/duplicar. Corrige la premisa caducada del ADR ("la validación resuelve síncrona"). Ambas capas incondicionales (texto libre, Regla #13). ABSORBIDO por el acuse universal por turno (feature 012 US1): `ackTurnBestEffort` acusa al inicio de TODO turno, así que este estado queda cubierto sin re-disparar — el propio código lo anota en `process-message.handler.ts` — done
- ⏳El bot deja de inventar la razón de un rechazo: dos motivos que se corrompían entre capas. (1) El motivo por el que una línea no está vinculada llegaba filtrado por una lista fija —lo que no reconocía se descartaba en silencio y aguas abajo se rellenaba con "sin vincular"—, así que un motivo nuevo no dejaba un hueco visible: se convertía en otro motivo, indistinguible del verdadero. Ahora el motivo se clasifica sin descartarse (presente / conocido / desconocido con su texto crudo) y un motivo que el bot no nombra se registra COMO desconocido. (2) De las 12 causas de rechazo que el servicio de recargas devuelve, una —el usuario no está dado de alta como vendedor— no estaba contemplada y salía como "intenta de nuevo en un momento": un rechazo permanente vestido de transitorio, que mandaba al vendedor a reintentar algo que no se arregla reintentando. Ahora tiene su mensaje propio, que dice a quién acudir. Una prueba lee el catálogo REAL del servicio de recargas y falla si aparece una causa que el bot no distingue — la reincidencia deja de depender de que alguien recuerde actualizar la lista
- ⏳Mensaje de fallo de recarga diferenciado por causa (feature 012 FR-014, contrato mensajes-v2 §9): el adapter dejó de colapsar 7 causas de svc-recharge (6×422 + 502) en un solo business_rule_violation — el bot ya no dice "intenta de nuevo" cuando reintentar es imposible (SIM ya recargada, límite diario). Catálogo completo obligado por el compilador (Record total → agregar causa sin mensaje rompe el build). Todas las causas son pre-cobro o cargo declarado fallido → "no se realizó ningún cargo" es verdad; el caso ambiguo queda fuera (texto libre, Regla #13) — done al validar runtime con piloto
- ✅Feedback de progreso — el bot nunca se percibe mudo (feature 012 US1): acuse leído+typing universal al inicio de TODO turno (antes solo 2 estados del FSM) + mensaje de estado "⏳ Procesando tu recarga de $X a Y…" antes de ejecutar (send_text, Regla #13). Constantes de umbral en código (no env var, doctrina 011); best-effort (un acuse que falla nunca rompe el flujo, FR-011); anti-ráfaga (1 acuse/turno). Mata #791 (silencio percibido). Validado runtime por el dueño en staging (bot real por WhatsApp) — done
- ⏳Drain-Job del consumer CAPA 4 (feature 008 conversión #3, T015-T017): drain-recharge-result-job drena el durable recharge-result por fetch() acotado (snapshot-y-salir, ADR-0061) para habilitar el flip cpu_idle sin estrangular el cierre de monitores (clase #651); orden sweep→drain garantizado por offset de fase en el cron (*/3 vs 1-59/3, E1 — no grace-window); test adversarial SC-002 que rompe si se reintroduce ACK dependiente de timing; nak acotado del #691 conservado — autoría entregada, apply/flip (T018) gateado por G1/G2/G3+gate 24h
- ✅Gate de autorización del bot por tabla vía HTTP (feature 014 PR-2, #883): el gate consulta legacy-primero y, ante no-reconocido, resuelve empleado por HTTP a svc-identity (POST /internal/employees/resolve-for-recharge) en vez del RPC NATS; identity-resolve.client.ts mapea 13 modos de error → unavailable (fail-closed, cero cae a legacy); elimina la lista PII employeePhoneSet + el flag EMPLOYEE_RECHARGE_RESOLUTION + la invocación RPC. Una sola forma de resolver (legacy→HTTP identity)
- ✅Copy conversacional v2 (feature 012 US2, contrato mensajes-v2 §2-§5): comprobante de éxito estructurado (monto/línea/operadora/folio/fecha en *negrita* nativa, escaneable <5s SC-002) + mensajes de error con anatomía de 4 preguntas + vía de escape interpolable (contacto_soporte) + aviso ambiguo honesto sobre el dinero + saludo con propósito. Gate copy-catalog (checklist 4 preguntas + denylist términos internos SC-006). Todo send_text in-session (Regla #13) — validado runtime con piloto real en staging (2026-07-24): comprobante de éxito (recarga succeeded, saga completa) + error por causa (sim_not_found con las 4 preguntas) confirmados por WhatsApp real
- ✅Identidad del principal EMPLEADO persistida en el monitoreo de vinculación (#946 Fase 1, migración 0010): el monitor acepta las DOS representaciones ortogonales del actor — eje legacy (vendedor/subdistribuidor) o principal_user_id (UUID de identity.users) — porque el empleado recarga por ownership-BYPASS vía RBAC y legítimamente NO tiene eje legacy. Antes la tabla solo modelaba la representación legacy: el monitor del empleado nacía sin identidad auditable y el guard NO_OWNERSHIP del sweep lo descartaba semanas después sin cobrar (2.092 recargas de SIMs YA vinculadas perdidas; las 8 cuentas afectadas ≡ exactamente el canal EMPLEADO). El fix propaga el UUID que ya existía en el context de la sesión (aggregate → side effect → repo), acota el guard al actor que le corresponde SIN aflojarlo (un monitor legacy sin actor sigue muriendo fail-closed) y firma el cobro con el canal employee del trust token. CHECK XOR con ELSE FALSE como gate estructural: un kind de principal nuevo sin rama declarada hace FALLAR el INSERT en vez de nacer mudo. 4 vectores de reversión verificados en rojo; CHECK validado contra Postgres real en DB throwaway con doble corrida (7 casos: 3 pasan, 4 rechazados)
- ✅Entrega confiable de las respuestas al vendedor: un envío que falla por una intermitencia se reintenta con espera creciente, y respeta el ritmo que la mensajería pide cuando pide esperar. Antes el fallo se anotaba y se descartaba —el turno se cerraba como exitoso y el mensaje se perdía sin recuperación posible, porque el bot solo puede escribir mientras la conversación siga abierta—. Cubre los siete puntos de salida, incluidos los dos del dinero: el aviso de cobro en duda y el del paso previo a cobrar. El aviso a quien no está registrado queda deliberadamente sin reintento (vive bajo la cuota que frena la enumeración de números). Un envío que agota sus intentos se cuenta y se registra como error desde los siete puntos —antes solo desde uno, y por eso tres días de mensajes sin entregar no se vieron desde afuera—, bajo el guardián que compara los avisos del código contra los filtros que los vigilan
- ✅El bot dejó de decirle "esa línea no está asignada a ti" a quien sí es su dueño. Cuando el sistema de inventario responde que el problema es QUIEN PREGUNTA —su alta no lo habilita a operar— y no la línea, el mensaje ahora lo dice así y aclara que no se arregla probando con otra SIM ni volviendo a intentar. Antes las dos causas llegaban al vendedor como un solo texto, porque el bot expresaba la pertenencia con un sí/no que no tenía forma para una tercera respuesta: la causa viajaba entera desde el inventario y se perdía al traducirse. En 35 días de operación dos personas hicieron 21 y 27 intentos sin una sola consulta exitosa, y una de ellas recorrió 5 líneas distintas buscando el problema donde el mensaje se lo señalaba. Los textos de rechazo temprano pasaron además a vivir en un catálogo que el compilador obliga a completar: una causa nueva sin su mensaje ya no compila, y un guardián comprueba que ningún rechazo repita el texto de otro
- ✅Las conversaciones que nunca arrancaron ya se cierran solas, y el barrido que las cierra tiene vigilancia. Una conversación que se abría pero no llegaba a avanzar quedaba abierta para siempre: el barrido la encontraba cada 3 minutos y la volvía a dejar igual —480 pasadas al día sobre la misma—, y como cada vendedor sólo puede tener una conversación abierta a la vez, la suya quedaba ocupada por ese resto y su siguiente mensaje continuaba una charla vieja en vez de empezar de cero. Ahora se cierran en la primera pasada. Además el barrido pasó a avisar si deja de correr: era el único proceso periódico sin vigilancia, así que si moría, las conversaciones dejaban de cerrarse y nadie se enteraba
- ✅Las pantallas del bot en el panel —sesiones, mensajes, configuración e indicadores— vuelven a exigir que la petición venga del panel. Como el bot tiene que recibir los mensajes de WhatsApp desde internet, su dirección es pública, y esas pantallas confiaban en unas cabeceras que cualquiera podía escribir: alguien que conociera la dirección podía leer las conversaciones con los números de los vendedores y cambiar la configuración del bot sin haber iniciado sesión. Ahora cada petición tiene que llegar firmada por el servicio de identidad del panel, que es el único que la firma; sin esa firma se rechaza antes de leer un solo dato. La búsqueda por número del listado de sesiones también dejó de aceptar comodines: buscar "%" ya no devuelve todas las conversaciones. Done al desplegar y comprobar en staging que el panel sigue mostrando las pantallas y que una petición sin firma se rechaza
- ✅El bot solo le dice al vendedor que no se hizo ningún cargo cuando puede probarlo. Si el servicio de recargas fallaba después de que la operadora ya había cobrado, el vendedor leía "No se realizó ningún cargo, intenta de nuevo" y podía pagar dos veces. Ahora solo recibe ese mensaje cuando el pedido no llegó a procesarse; en cualquier otro caso recibe el aviso de que el cobro pudo haberse aplicado y el contacto de soporte
- ✅Una recarga que quedó sin respuesta ya no se cierra con una invitación a volver a intentarla. El barrido tiene una vía para las recargas colgadas, que avisa que el cobro pudo haberse aplicado y remite a soporte, y otra para las conversaciones inactivas, que invita a mandar el número de nuevo. Si la primera vía fallaba, o si había más de 100 recargas colgadas a la vez, las que sobraban caían en la segunda y el vendedor recibía la invitación a reintentar un cobro que quizá ya había salido: podía pagar dos veces. Ahora una recarga sin resultado siempre recibe el aviso honesto, venga por la vía que venga, y el criterio de "el cobro pudo haber salido" vive en un solo lugar para todo el bot
- ✅El aviso de que un cobro pudo haberse aplicado llega aunque falle el cierre de la conversación. Cuando el barrido rescata una recarga colgada, primero la da por terminada, después cierra la conversación y al final avisa al vendedor. Si el cierre fallaba, el aviso no salía, y en la pasada siguiente la conversación se cerraba sin decir nada: el vendedor nunca se enteraba de que el cobro pudo haber salido. Ahora el aviso sale igual
- ✅Cuando la base de datos del bot falla, sus registros ya no guardan el número de nadie. Al fallar una consulta, el error que devuelve la base trae los datos con los que se hizo la consulta —el número de quien escribe, el de la línea a recargar dentro del texto de un mensaje, o lo que un operador buscó en el panel—, y el bot copiaba ese error entero en sus registros, al lado del número que en la misma línea sí aparecía tapado. Ahora de un error de la base sólo se registra qué tipo de falla fue, que es lo que se necesita para diagnosticarla, y ese dato vuelve a leerse bien: antes se buscaba en el lugar equivocado y los registros decían siempre "sin código", aun cuando la base avisaba que estaba saturada. Un control automático impide volver a copiar un error entero en un registro
- ✅Cuando WhatsApp acepta una respuesta del bot pero no la entrega, ahora lo sabemos y sabemos por qué. Los avisos de entrega que manda WhatsApp (enviado, entregado, leído, fallido) se descartaban todos porque el bot buscaba su hora en el lugar equivocado; ahora se leen, se cuentan por tipo, y cada fallo queda registrado con el código y el motivo que da WhatsApp, sin el número del vendedor
- ✅El bot ya no pierde respuestas cuando un vendedor le escribe muy seguido. WhatsApp sólo deja mandarle a una misma persona un mensaje cada 6 segundos (con una ráfaga corta de margen); cuando el bot se pasaba, WhatsApp rechazaba el envío y, tras tres intentos rápidos, la respuesta se perdía. Ahora el bot lleva la cuenta por vendedor y, si va muy rápido, deja las respuestas en fila y las manda en orden en cuanto WhatsApp lo permite, sin frenar el cobro ni a los demás vendedores. Si WhatsApp igual rechaza, espera lo que WhatsApp pide antes de reintentar
- ✅El detalle de una conversación en el panel ya no muestra la etiqueta de plantilla de WhatsApp. El bot no manda plantillas y nadie guarda ya ese dato, así que la etiqueta sólo aparecía en mensajes viejos; se quitó de la pantalla y de lo que el bot le entrega al panel
- ✅El bot manda menos mensajes por venta, porque WhatsApp cobra cada uno desde octubre. Una venta rápida son 2 mensajes: la confirmación, que ya dice que la línea quedó verificada, y el recibo. El aviso de "estoy validando" sólo sale si la validación tarda más de 5 segundos; ya no se manda "procesando" antes del recibo ni el aviso de cierre por inactividad; y a una línea que ya está en monitoreo no se le vuelve a ofrecer, se le dice en el mismo mensaje
- ✅Los registros del bot ya no llevan escondido el número del vendedor. El identificador que WhatsApp le pone a cada mensaje trae el teléfono completo adentro, y se anotaba tal cual en cada envío, cada aviso de entrega y cada mensaje procesado. Ahora se anota una huella corta que sirve para seguir el mismo mensaje de punta a punta sin revelar el número; lo que se guarda y lo que se le manda a WhatsApp no cambia
- ✅La confirmación de venta ya no dice que la línea está vinculada cuando no lo está. Hay líneas que no necesitan vincularse para recargarse (por su fecha o porque su producto no lo exige), y el bot igual les decía "verificada y vinculada correctamente". Ahora a esas les dice sólo "verificada"; a las que sí están vinculadas les sigue diciendo lo mismo
- ✅Cuando el proveedor no alcanza a dar folio, el bot ya no le dice al vendedor que reintentar no sirve. Sin folio no hubo cobro y la línea se libera en ese momento, así que ahora le dice que no se hizo ningún cargo y que intente de nuevo en un momento; los rechazos definitivos del proveedor siguen diciendo que reintentar no cambia el resultado
Motor de consulta hacia el MySQL legacy de REYVA: recibe una consulta por HTTP, la ejecuta y devuelve filas. Único componente con acceso al legacy. No decide nada del negocio — no clasifica ni concluye sobre lo que devuelve. NO maneja TurboCarga (vive en svc-recharge) ni vinculación con operadora (vive en svc-vinculacion).
- ✅Bootstrap PR-A (scaffold Nest+Fastify, health + métricas, Dockerfile + compose)
- ✅PR-B: pool MySQL (charset utf8) + consultas del padrón subdistribuidor/vendedor + mapping operador/estatus/sim + caché Redis + TrustToken
- ✅PR-C: consulta de inventario por DN
- ✅PR-E: hardening (probe mysql_user_is_read_only + circuit breaker + runbook)
- ✅Tests unit (adapter + casos de uso + hardening)
- ✅ADR-0058: conector de escritura segregado (GRANTs ⊆ INSERT TURBOCARGA + UPDATE INVENTARIO, guard legacy_write_grants_compliant) + reserva pesimista de línea (tomar/soltar), registro de asiento y resolución de SKU — anti doble-cargo bot↔portal
- ✅ADR-0068: el puente expone consultas parametrizadas, no operaciones de negocio — las reglas de dominio quedan en el servicio dueño del concepto (svc-inventory la línea, svc-recharge el asiento)
- ✅ADR-0068: un solo idioma, HTTP — 8 endpoints sobre padrón, catálogo, inventario y asientos. El puente no publica eventos ni consume streams
- ✅ADR-0068: listado paginado por keyset del padrón (POST /v1/padron/{vendedores,subdistribuidores}/consulta) — devuelve la fila completa, las mismas columnas que la consulta por teléfono
- ✅ADR-0068: el trust token consume el nonce (no sólo lo lee) y falla cerrado si el registro de nonces no responde — ante la duda entre petición nueva y repetida, se rechaza
- ⏳TLS autenticado contra el MySQL legacy (hoy cifra sin autenticar; depende del proveedor o de una VPN — gate ADR-0067)
- ✅Una pausa del sistema viejo ya no deja al bot diciendo "No pude validar tu acceso" a todos. Cada consulta tiene hasta 1,5 segundos para responder (antes medio segundo), las conexiones abiertas se mantienen vivas aunque no haya tráfico, y una consulta lenta ya no cuenta como si el sistema viejo estuviera caído: sólo una falla de conexión corta las consultas durante un minuto. Quien espera la respuesta espera un poco más que el sistema viejo, para que sea éste el que diga qué pasó
- ✅La consulta de una línea trae también su fecha de alta, el hecho que separa una línea apartada de una ya recargada; lo que el inventario y el bot ven no cambia
- ✅Si apartar una línea falla porque otra venta la tenía trabada un instante, y el sistema viejo confirmó que no quedó nada a medias, se vuelve a intentar una sola vez tras una pausa breve, sólo si ese segundo intento termina, en el peor caso, antes de que el inventario deje de esperar. Si otra venta la tomó en ese lapso, no se aparta dos veces. Un asiento que no consigue conexión a tiempo ya no se ejecuta tarde: falla sin escribir y se reintenta en diferido
Lectura read-only del inventario REYVA legacy. Verifica que cada línea consultada por el bot pertenezca al inventario activo de REYVA antes de procesar cualquier recarga.
- ✅Esquema DB y migraciones
- ✅Bootstrap MVP (proveedor simulado determinístico, TrustToken)
- ✅API REST/gRPC implementada
- ✅La proyección local de SIMs se retira: la tabla nunca tuvo escritor y el candado que impide cobrar dos veces la misma línea se ejerce contra el inventario real, no contra una copia. Se van con ella los endpoints /v1/sims/*, sus contratos y el job que liberaba reservas vencidas
- ✅El identificador de apartado viaja FIRMADO: cubre la línea, el estado previo y el chip, así que uno escrito a mano ya no suelta una línea que este servicio nunca apartó ni le escribe un estado elegido por quien llama (ADR-0058 §Riesgos)
- ✅LegacyBridgeInventoryProvider reemplaza mock (ADR-0013, Ola 2)
- ✅Consulta de inventario contra svc-legacy-bridge por HTTP: el puente devuelve la fila cruda y svc-inventory decide qué significa — la pertenencia de la línea se calcula acá, no se transporta (ADR-0068)
- ✅Lectura read-through vía svc-legacy-bridge
- ✅El registro de consultas se retira junto con el endpoint que lo escribía, que no tenía invocador. Con él se van su purga por retención y la cola que la corría: el servicio deja de tener base de datos y de guardar el teléfono consultado bajo ninguna forma (LFPDPPP, ADR-0027)
- ✅Contratos versionados en shared-contracts para POST /v1/lines/{clearance,reserve,release} — el número del cliente viaja en el cuerpo, no en la URL
- ✅La causa "operadora fuera de alcance" llega al vendedor en vez de degradarse a "el inventario no respondió"
- ✅La identidad del actor deja de viajar como un dato ausente y pasa a decir QUÉ se supo de ella: el subdistribuidor dado de baja ya no se confunde con "el padrón no respondió". Quien consultaba con su alta vencida recibía "el inventario no responde" y un reintento automático que no podía funcionar; ahora recibe la causa real, una sola vez
- ✅Test de frontera con svc-recharge: las causas del emisor real pasan por el esquema real del consumidor
- ✅Dos desenlaces que se veían sanos quedan anclados por pruebas: una respuesta ilegible del motor ya no se le presenta al vendedor como "esa línea no es de REYVA", y una línea que no se pudo soltar ya no se reporta como soltada — se registra para que la reconciliación la libere
- ⏳Tests integración contra réplica MySQL legacy (Ola 2)
- ⏳Tests integración + e2e
- ⏳Deploy staging
- ✅Deploy producción (build prod-only, sin canary: no es servicio público del LB)
- ✅El significado de cada estado de la línea sale del catálogo del sistema viejo: sólo Preactivada y Por confirmar se recargan, la línea tomada se informa como apartada o vendida, y cualquier otro estado se rechaza con su descripción en vez de "servicio no disponible"
Verificación de vinculación con AT&T, Movistar y Unefon. Consulta a cada operadora si una línea está vinculada con su titular (requisito CRT 2026). Solo retorna sí/no — no almacena datos personales (LFPDPPP, ADR-0027).
- ✅Scaffold NestJS + estructura hexagonal + value objects
- ✅Puerto VinculacionProviderPort + registry por operadora (ADR-0051 §1)
- ✅Use case check-vinculacion con métricas + emisión audit
- ✅TrustTokenGuard HMAC + controllers HTTP
- ✅Adapter AT&T scaffold con payload getClientesBiometricos
- ✅Esquema vinculacion.query_log extendido (result/reason/elapsed_ms/saga_id) + retención 5y (ADR-0027, Ola 2)
- ✅Job de retención 5y + migración phone_hmac HMAC-SHA256 (ADR-0027 addendum 2026-05-29, S2.11)
- ⏳Spike AT&T: validar auth real contra sandbox/prod (Ola 2)
- ⏳Spike Movistar: descubrir response shape contra QA (Ola 2)
- ✅Adapter AT&T real WSNED (getClientesBiometricos, parseo robusto jsonResponse + 408, portado del legacy)
- ✅Adapter Movistar real (GET enrolamiento, parseo enrolado, credenciales por env)
- ✅Adapter Unefon real (reusa cliente WSNED de AT&T, operator propio)
- ✅Credenciales/endpoints de operadoras por env (Secret Manager en prod, fail-fast prod+real, .env.legacy gitignored en dev)
- ⏳Spike AT&T/Movistar: validar auth real contra sandbox/prod (requiere credenciales)
- ✅Política fail-closed validada con tests (ADR-0051 §3)
- ✅Fix P0 mapStatusVinculacion: "NO EXITOSA" ≠ linked (substring bug) + test adversarial fail-closed
- ✅El 503 declara su causa: la respuesta distingue la caída de la operadora del corte propio por plazo, y el bot deja de atribuirle a la operadora un fallo nuestro
- ✅El motivo del pendiente nombra lo que la operadora dice ("en revisión", no "documentación incompleta") y se omite cuando no lo da
- ⏳Métricas + alertas unknown_rate > 5% por operador
- ✅Cada consulta deja una línea con su operadora y si obtuvo respuesta válida: alimenta la alerta de operadora que no está validando líneas
- ✅Integración saga svc-recharge paso 2 CHECK_VINCULACION
- ⏳Tests integración + e2e contra sandbox/QA
- ✅Circuit breaker por operadora en la validación de vinculación (feature 010 US2: corto-circuito tras N timeouts, half-open pass-through, métricas de estado/transiciones)
- ✅Deploy staging (vinculación REAL en el bot: STUB_VINCULACION=false, providers de operadora reales)
- ✅Deploy producción (build prod-only, sin canary: no es servicio público del LB)
- ✅Calendario oficial de vinculación por último dígito (DOF 30-jun-2026): quien pregunta recibe un sí o un no ya resuelto, sin conocer las fechas. El plazo lo resuelve el inventario, que es quien tiene la fecha de activación de la SIM; este servicio se limita a consultar a la operadora (ADR-0051 §A11)
Trazabilidad interna de REYVA: registro permanente e inalterable de cada operación del sistema (recargas, validaciones, cambios de permisos, accesos, configuración). Cubre módulos actuales y futuros. Hash chain criptográfico + WORM en Cloud Storage (ADR-0033) — nadie, ni siquiera un administrador, puede borrar o alterar registros sin que sea detectable.
- ✅Esquema DB y migraciones
- ✅Hash chain HMAC + función append_event (ADR-0033 §1)
- ✅Idempotencia exactly-once por envelope_event_id (ADR-0028)
- ✅Verificación periódica de tampering (ADR-0033 §1.4)
- ✅API POST /audit/events
- ✅API GET /audit/events con filtros + paginación cursor-based
- ✅Auth s2s ADR-0026 (X-User-Trust-Token)
- ✅Export periódico a Cloud Storage WORM (ADR-0033 §2)
- ✅Métricas Prometheus + endpoint /metrics
- ✅Consumer reyva.identity.access (JetStream durable + Nats-Msg-Id dedup)
- ✅Consumer reyva.identity.permission_changed: JetStream durable + Nats-Msg-Id dedup → hash chain (ADR-0055 FU-3)
- ✅Tests integración + e2e (87 con docker-compose: postgres+nats)
- ✅Cloud Run staging deploy (Fase 0)
- ✅Application layer migrado a Result<T, E> (errores tipados, sin throw en use-cases/repos)
- ✅docker-compose dev incluye svc-audit (Dockerfile dev tsx watch, puerto 47412, HMAC compartido con svc-identity para reverse-proxy P12)
- ✅PII en payload hasheada/truncada (ip_hash + user_agent_truncated, audit P1-3 / ADR-0033 addendum 2026-05-29)
- ✅Rotación trimestral de signing keys (cron 90d + NATS reyva.audit.signing_key_rotated, audit P0-4 / ADR-0033 §rotación)
- ✅Deploy producción (Fase 2): bucket WORM reyva-audit-worm-prod retención 5a + export-to-worm + consumers de REYVA_IDENTITY + pantalla /admin/auditoria (reenviada por svc-identity)
- ✅Audit-on-write de empleados: EmployeeChangedConsumer persiste reyva.identity.employee_changed en la hash-chain WORM (resource_kind=identity_employee). Cierra el simétrico del audit-on-read de PII — aquél registra quién MIRÓ el teléfono del empleado, éste quién lo CAMBIÓ (el dato que dirige a quién le cobra el bot). El publisher declaraba este consumer desde PR-A y no existía
- ✅El audit-on-write del teléfono deja de nombrar una entidad que no existe: el subject pasa a reyva.identity.user_telefono_changed y la fila a resource_kind=identity_user (antes employee_changed/identity_employee, que afirmaban un subtipo employees retirado en la migración 0017). NO se reusó reyva.identity.user_changed: tiene consumidor vivo y distinto (svc-notifications-user-changed, cache de opt-out). La cadena NO se toca — lo congelado del canonical de ADR-0033 son las doce CLAVES, no sus valores; verify_chain devuelve 0 problemas sobre las filas nuevas, y las históricas no se reescriben (WORM append-only), así que la serie resource_kind queda partida en dos valores y el filtro por igualdad exacta de GET /audit/events ve sólo la mitad si se consulta uno — documentado en ADR-0033 §Addendum 2026-09-18. Transición sin corte seco: dos durables simultáneos (una sola clase parametrizada) hasta que el stream expire los mensajes del subject viejo (max_age 7d), con receta de retiro MEDIBLE en el class doc. El gate check-subject-consumers.ts se amplió para ver un consumer parametrizado: con filterSubject: this.config.subject reportaba el subject nuevo como huérfano teniendo el consumer al lado — falso positivo verificado con mutación
- ⏳Auditoría de accesos a datos personales del legacy: el servicio que atiende la consulta registra su propio acceso por POST /audit/events, indicando a quién atendía y sobre qué recurso del dominio
- ✅Bucket WORM con retención de 5 años SIN retention lock, por decisión del dueño: el lock es irreversible y queda como decisión aparte y explícita. El gate check-worm-lock.ts bloquea un is_locked=true en el código para que ningún apply lo active de arrastre
- ✅Worker de proyección always-on (minScale=1 + CPU always-allocated) — el trail de auditoría no deja de ingerir accesos/permisos (Constitution Principio VI, compliance LFPDPPP)
- ✅Esquema declarado por tipo de evento: POST /audit/events y los consumidores rechazan la acción o el payload no declarados (accion_no_declarada / payload_no_declarado) — lista cerrada, ADR-0033 §Addendum 2026-10-06
- ✅verify-chain reconoce una purga declarada en la propia cadena (evento audit.events_purged con puentes firmados) y sigue detectando cualquier borrado no declarado
- ⏳Purga de las filas legacy.access con hash reversible de PII (bucket WORM + audit.audit_events) en prod
Pantalla en tiempo real de la operación (recargas hoy, bloqueadas, fallidas por operador), alertas inteligentes de negocio (operador caído, vendedor con fallas, tasa de vinculación anómala) y paneles técnicos para soporte.
- ✅Scaffold NestJS + Fastify + health probes (BFF stateless, ADR-0057)
- ✅Cliente Prometheus API + consumer NATS read-only (ADR-0057)
- ✅Métricas de negocio en tiempo real
- ✅Reglas de alerta inteligente (SLOs ADR-0056)
- ✅Alertas de negocio (operador caído) + publish reyva.monitoring.alert_triggered (ADR-0057 §5)
- ✅Alerta no_traffic con horario operativo + heartbeat scheduler (ADR-0057 addendum)
- ✅Pantalla /admin/monitor en el panel (reenviada por svc-identity, ADR-0057)
- ✅Integración con Grafana (dashboard svc-monitoring + scrape target)
- ⏳Tests integración + e2e
- ✅Deploy staging
- ✅Deploy producción (recharge-live por NATS; monitoreo técnico SLO diferido a Cloud Monitoring)
- ⏳Monitoreo técnico SLO en prod vía Google Cloud Monitoring (trigger: módulo infra-observability)
- ✅Worker de fondo always-on (minScale=1 + CPU always-allocated) — el consumer recharge-live no escala a cero, el pulso en vivo no se detiene (Constitution Principio VI)
BackendOla 3svc-history
Historial de Recargas
🔄En construcción
Consulta histórica de cada recarga ejecutada vía bot: número, timestamp, operador, autor, resultado y transacción. Filtros por fecha, operador y punto de venta. Exportación a Excel/CSV.
- ✅Scaffold NestJS + Fastify + health probes + bootstrap
- ✅Esquema DB history.recharges + processed_events + migración (ADR-0052 §2.1)
- ✅Consumer NATS proyección reyva.recharge.* con dedupe por event_id (ADR-0052 §1)
- ✅API REST GET /history/recharges con filtros + paginación
- ✅Exportación CSV/Excel streaming (límite 50k filas, ADR-0052 §2.1)
- ✅Métricas Prometheus read_model_* (lag, processed, duration) (ADR-0052 §5)
- ✅Job retención nocturno + pseudonimización PII >5 años (ADR-0052 §4)
- ✅Fail-fast HISTORY_ERASURE_SALT en prod-like — retención LFPDPPP sin fallback silencioso (#777)
- ✅Tests unit consumer (mapeo + dedupe + outcomes)
- ✅Auth s2s (reverse-proxy svc-identity → svc-history + enrichment display_name)
- ✅Pantalla panel /admin/historial (tabla filtrable + export CSV/Excel)
- ✅Test rebuild desde NATS — invariante idempotencia (ADR-0052 §3)
- ✅Worker de proyección always-on (minScale=1 + CPU always-allocated) — no escala a cero (Constitution Principio VI)
- ✅Recarga cortada antes de apartar línea: sin operadora ni monto ("—"), sin relleno telcel/1 centavo
- ⏳Tests integración consumer + e2e proyección end-to-end
- ✅Deploy staging
- ✅Deploy producción
BackendOla 3svc-reports
Reportes Ejecutivos
🔄En construcción
Resúmenes automáticos diarios, semanales y mensuales en PDF. Comparativas contra periodos anteriores. Envío automático por email a stakeholders configurados, sin necesidad de entrar al sistema.
- ✅Servicio read-model on-demand (registry + serializers Excel/PDF)
- ✅Reportes semilla: desempeño por vendedor + resumen diario
- ✅Evento reyva.reports.generated a NATS (emisión a stream propio REYVA_REPORTS, 30d). Trazabilidad de "quién generó qué reporte" sin persistir el artefacto; AÚN SIN CONSUMER — svc-notifications de Ola 4 lo consumirá para el email (ADR-0052 §Notas svc-reports). Decía "(auditoría)", que prometía una persistencia en el audit trail WORM que no ocurre
- ✅Tests unitarios (registry, serializers, transformaciones)
- ✅Auth s2s (reverse-proxy svc-identity → svc-reports con trust token)
- ✅Pantalla panel /admin/reportes (selector plantilla + filtros + descarga Excel/PDF)
- ⏳Schedule via BullMQ (ADR-0024) — Ola 4
- ⏳Envío email vía svc-notifications — Ola 4
- ✅Deploy staging
- ✅Deploy producción
Vista de actividad por punto de venta o subdistribuidor: recargas hechas, monto, tasa de éxito, última actividad. Distingue subdistribuidor vs vendedor (rol de primera clase). A cada actor lo identifica su teléfono: el padrón vive en el sistema anterior y se consulta ahí, no se copia.
- ✅Scaffold NestJS + Fastify + health probes + bootstrap
- ✅Schema DB (kpi_snapshots + recharge_ref + processed_events) + migración (ADR-0052 §2.3)
- ✅Consumer NATS agregación KPIs por (vendedor, día, fuente) + tabla puente para tasa de éxito
- ✅El padrón de vendedores/subdistribuidores llega DIRECTO del puente (sin escala en svc-identity) y se consulta por TELÉFONO
- ✅Distinción subdistribuidor/vendedor: la clase viaja en el evento (la dice su subject), ya no se infiere best-effort
- ✅Columna source extensible (whatsapp_bot + futuras fuentes sin migración)
- ✅API REST: ranking (GET) + gate de admisión del bot (POST, el teléfono en el cuerpo), los dos con trust token verificado
- ✅Métricas Prometheus read_model_* + tests unit consumer
- ✅Rebuild desde NATS (script rebuild-from-nats + test de integración del invariante, ADR-0052 §3)
- ✅Auth s2s: svc-distributors VERIFICA el trust token que le firman (svc-identity para el panel, svc-conversation para el gate)
- ⏳Pantalla panel /admin/vendedores (ranking con filtros + semaforización)
- ⏳El panel se re-cablea al contrato vigente del read-model: consume la ruta y los campos que el servicio publica hoy
- ⏳Tests e2e (panel admin Inteligencia de negocio)
- ⏳Contador vinculacion_blocked (requiere evento reyva.vinculacion.checked, pendiente)
- ✅El gate de admisión del padrón responde por HTTP con contrato compartido (deja de ser NATS request/reply)
- ✅Deploy staging
- ⏳Deploy producción (el servicio verifica DOS callers; falta cablear el secreto del segundo para que el panel no reciba 401)
- ✅Worker de proyección always-on (minScale=1 + CPU always-allocated) — no escala a cero, la proyección del read-model no se congela (Constitution Principio VI)
- ✅Backfill kpi_snapshots business_subject → legacy_actor aplicado en prod (migración 0003 + re-atribución 0013 en recharge; cierre de los ceros del panel)
- ✅El padrón NO se copia: se consulta en vivo al sistema anterior, como a un motor de datos. Se retira la réplica local (subject_dim) que se llenaba en el camino del gate de admisión y envejecía sola — quedaban activos actores que ya habían cambiado de teléfono
Avisos automáticos por email a puntos de venta, vendedores y dirección (recarga ejecutada por monitoreo, línea no vinculada a tiempo, corte diario de pendientes, fallas de operador, recordatorios). Mensajes masivos / por segmento y notificaciones configurables. WhatsApp se reserva al bot de venta.
- ✅Scaffold NestJS + Fastify + health probes + bootstrap
- ✅Canal email: puerto EmailSender + adapter Gmail API (Domain-Wide Delegation keyless) + worker despacha por canal (flag EMAIL_SEND_ENABLED)
- ✅Canal email: catálogo de plantillas en código (subject+html+text) + plantilla recarga_monitor_completada (comprobante de recarga)
- ✅Canal email: DWD del SA viva (Gmail API + SPF/DKIM/DMARC + binding serviceAccountTokenCreator) + envío real verificado
- ✅Go-live prod del canal email (specs/005): infra Terraform dedicada (servicio privado + DWD prod + 7 migraciones), gate OFF validado (suppressed_flag) → flag ON, primer comprobante real entregado e idempotencia verificada con tráfico real (2026-07-09)
- ✅Consumidor monitor recarga OK → email al vendedor (svc-recharge enriquece correo+monto y emite notification.requested channel=email; envío gated por canal email)
- ⏳Consumidor monitor expirado → email al vendedor
- ⏳Corte diario por vendedor (pendientes de monitoreo) → email
- ⏳Envío de reportes svc-reports por email a dirección
- ✅Worker de envío BullMQ + consumer NATS reyva.notification.requested
- ✅Guardia de idempotencia del envío por correlation_id (ADR-0028 §3.2): no reenvía tras reintento interno de BullMQ post-lock-loss
- ✅Worker refactorizado a registry de canales + pipeline de behaviors (idempotencia): agregar un canal = 1 handler + 1 binding, sin tocar el orquestador (ADR-0053)
- ✅Recuperación automática de envíos huérfanos + auditoría de aceptación del proveedor (feature 009)
- ✅Sólo el Job notifications-drain procesa la cola: el servicio HTTP ya no registra worker BullMQ ni consumers NATS (staging y prod)
- ✅Retirado el canal WhatsApp: envío HSM, opt-out, plantillas de Meta con su sync y la pantalla del panel (las notificaciones salen por correo)
- ✅Retirado el servicio Cloud Run svc-notifications: el Job notifications-drain es su único proceso (el deploy construye la imagen y sólo actualiza el Job)
- ✅Borradas de la base las tablas sin lectores del canal WhatsApp: plantillas, su historial de estado y eventos procesados
- ✅Retirados los eventos reyva.notification.delivered/failed, que nadie consumía: la constancia del envío queda en los logs y en sent_notifications
- ⏳Tests integración + e2e
- ✅Deploy staging
- ✅Deploy producción + canary
- ✅El envío de correo (puerto, adaptador Gmail DWD, catálogo de plantillas) se mudó a @reyva/shared-infrastructure/email para que svc-identity envíe la contraseña directo con el mismo adaptador; el drain no cambia de comportamiento (ADR-0069)