Runbooks
Playbooks operacionales para los incidentes más comunes de SipSop. Cada runbook sigue el formato: Síntoma → Causa probable → Pasos → Verificar → Escalación.
Listo para copiar y pegar
Todos los comandos SQL y shell de esta página están listos para copiar y pegar. Reemplazar los placeholders entre corchetes como <tenant_id> con valores reales.
1. Tenant no puede pagar — past_due por más de 7 días
Síntoma: Un tenant está bloqueado en estado past_due y el reintento automático no lo resolvió. El cliente contacta soporte diciendo que no puede usar el portal.
Causa probable: El schedule de reintentos de Stripe se agotó. Tarjeta vencida o rechazada. El email de billing del tenant no está recibiendo las notificaciones de Stripe.
Pasos
Paso 1 — Verificar en Stripe
- Abrir Stripe Dashboard → Customers → buscar por
stripeCustomerId(visible en/admin/tenants/:id/overview). - Ver el estado de la suscripción y el historial de facturas.
- Si hay una factura fallida: hacer click → "Retry payment" (botón en el dashboard de Stripe).
- Si el método de pago está vencido: contactar al cliente para que actualice su tarjeta desde la sección de billing del portal (link al portal de billing de Stripe).
Paso 2 — Verificar estado local del tenant
SELECT id, name, status, stripe_status, stripe_subscription_id, access_ends_at
FROM tenants
WHERE id = '<tenant_id>';Paso 3 — Forzar un stripe-sync para actualizar el estado local después de que Stripe resuelva
Disparar el job BullMQ stripe-sync manualmente:
# Desde el servidor API o localmente con el env correcto
node -e "
const { Queue } = require('bullmq');
const q = new Queue('stripe-sync', { connection: { host: 'localhost', port: 6380 } });
q.add('manual-sync', { tenantId: '<tenant_id>' });
q.close();
"Paso 4 — Si Stripe resolvió pero DB sigue mostrando past_due, actualizar manualmente
UPDATE tenants
SET status = 'active',
stripe_status = 'active',
updated_at = NOW()
WHERE id = '<tenant_id>';Paso 5 — Comunicarse con el cliente
Enviar email de confirmación una vez que se restaure el acceso.
Verificar: Loguear como el client_admin del tenant (o impersonarlo desde /admin/tenants/:id/users) y confirmar que el banner de past_due desapareció.
Escalación: Si Stripe muestra la suscripción como cancelada (no solo past_due), la suscripción necesita recrearse. Contactar soporte de Stripe o volver a ejecutar el flujo de checkout.
2. Fallo de webhook de Stripe
Síntoma: Stripe Dashboard → Webhooks → Endpoint muestra deliveries fallidas. El estado del tenant no se actualiza. Las facturas no se marcan como pagadas.
Causa probable: El servidor API está caído o no es accesible. Verificación de firma del webhook fallida (STRIPE_WEBHOOK_SECRET incorrecto). Nuevo tipo de evento que SipSop no maneja (no fatal — retorna 200 para eventos no manejados).
Pasos
Paso 1 — Verificar delivery del webhook en Stripe
- Stripe Dashboard → Developers → Webhooks → click en tu endpoint.
- Revisar el tab "Failed attempts". Leer el cuerpo de la respuesta de error.
- Anotar los IDs de eventos de deliveries fallidas.
Paso 2 — Verificar logs de la API
# Azure Container Apps:
az containerapp logs show --name sipsop-api --resource-group sipsop-rg --follow
# O Sentry — filtrar por 'webhook' o 'stripe'Paso 3 — Reenviar eventos fallidos desde Stripe
- En Stripe Dashboard → Webhooks → endpoint → hacer click en un evento fallido.
- Click "Resend" (arriba a la derecha). Stripe reintentará la delivery una vez.
- Para múltiples eventos, usar Stripe CLI:
stripe events resend <evt_xxxx>
stripe events resend <evt_yyyy>Paso 4 — Verificar idempotencia
Los manejadores de webhook de SipSop son idempotentes — reenviar el mismo evento múltiples veces es seguro. Los manejadores verifican el estado existente antes de actualizar:
invoice.paid: busca factura porstripe_invoice_id, solo actualiza si status !=paidsubscription.updated: actualizatenants.stripe_statusincondicionalmente (seguro)
Paso 5 — Si la verificación de firma está fallando
Verificar que la variable de entorno esté configurada correctamente:
# Verificar en Azure Key Vault
az keyvault secret show --vault-name sipsop-kv --name sipsop-stripe-webhook-secretComparar con el signing secret que muestra Stripe Dashboard → Webhooks → endpoint → "Signing secret".
Verificar: Después del reenvío, verificar tabla invoices:
SELECT id, stripe_invoice_id, status, paid_at
FROM invoices
WHERE stripe_invoice_id = '<stripe_invoice_id>';Escalación: Si la API sigue devolviendo 5xx en el delivery del webhook, el problema probablemente es un bug a nivel de código. Verificar Sentry para el stack trace.
3. CDR sync detenido / FreePBX no accesible
Síntoma: Los registros de llamadas no se actualizaron en 15+ minutos. /admin/dashboard muestra 0 llamadas este mes o datos desactualizados. La cola BullMQ cdr-sync muestra jobs fallidos.
Causa probable: MariaDB de FreePBX no accesible. Credenciales incorrectas. Problema de red entre la API y pbx.sopinf.xyz. Alta carga en el PBX causando consultas lentas.
Pasos
Paso 1 — Probar conectividad a MariaDB
# Desde el servidor/container de la API
mysql -h pbx.sopinf.xyz -P 3306 -u <FREEPBX_DB_USER> -p<FREEPBX_DB_PASSWORD> asteriskcdrdb \
-e "SELECT COUNT(*) FROM cdr WHERE calldate > DATE_SUB(NOW(), INTERVAL 1 HOUR);"Si la conexión se agota: problema de red. Verificar el egress de Azure Container Apps y las reglas de firewall en el host del PBX.
Si la conexión funciona pero devuelve datos inesperados: cambio en la configuración de FreePBX. NO modificar la configuración de FreePBX — es de SOLO LECTURA.
Paso 2 — Verificar jobs fallidos en BullMQ
redis-cli -p 6380 LLEN bull:cdr-sync:failed
redis-cli -p 6380 LRANGE bull:cdr-sync:failed 0 4Paso 3 — Leer el último error
Los detalles del error están en el campo failedReason del job en Redis. Usar el método getFailedJobs() de BullMQ o inspeccionar via Bull Board.
Paso 4 — Re-ejecutar CDR sync manualmente
redis-cli -p 6380 LPUSH "bull:cdr-sync:wait" \
'{"id":"manual-1","name":"cdr-sync","data":{},"opts":{"attempts":1}}'O reiniciar el worker para que tome el próximo run programado:
# Azure Container Apps
az containerapp revision restart --name sipsop-worker --resource-group sipsop-rgPaso 5 — Verificar registros duplicados
El CDR sync usa ON CONFLICT (pbx_server_id, freepbx_uniqueid) DO NOTHING — seguro re-ejecutar múltiples veces sin duplicar registros.
Verificar:
SELECT MAX(calldate) as ultimo_cdr, COUNT(*) as llamadas_hoy
FROM cdr_records
WHERE calldate > CURRENT_DATE;Escalación: Si FreePBX es verdaderamente inaccesible (servidor PBX caído), contactar al equipo de red. Nunca intentar reiniciar o reconfigurar FreePBX — sirve llamadas en vivo.
4. Resetear contraseña de client_admin o client_user
Síntoma: Un cliente no puede loguearse y el email de "contraseña olvidada" no le llega (problema de entrega de email o email incorrecto).
Causa probable: Dirección de email incorrecta usada al registrarse. Servicio de email mal configurado. El proveedor de email del usuario está bloqueando el email.
Pasos
Paso 1 — Confirmar que el usuario existe
SELECT id, email, name, role, is_active, email_verified
FROM "user"
WHERE email = 'cliente@ejemplo.com';Paso 2 — Si la dirección de email está mal, actualizarla
UPDATE "user"
SET email = 'correcto@ejemplo.com',
email_verified = false,
updated_at = NOW()
WHERE id = '<user_id>';Paso 3 — Disparar email de reset de contraseña
Usar el admin endpoint de Better Auth (si el plugin admin está habilitado en la config de auth):
curl -X POST http://localhost:3002/api/auth/admin/set-password \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <admin_session_token>" \
-d '{"userId": "<user_id>", "newPassword": "Temp#Pass123!"}'Alternativamente, hacer que el usuario vaya a la página de login y haga click en "Forgot password" después de corregir su email.
Paso 4 — Si se setea una contraseña temporal, comunicarla de forma segura
Better Auth no tiene aún un flag de "forzar cambio en próximo login". Comunicar la contraseña temporal de forma segura al cliente y pedirle que la cambie inmediatamente desde Settings.
Paso 5 — Invalidar sesiones existentes
DELETE FROM session WHERE user_id = '<user_id>';Verificar: Probar el login impersonando al usuario desde /admin/tenants/:id/users.
Escalación: Si la API admin de Better Auth no está disponible o configurada, el hash de contraseña debe actualizarse directamente en la tabla account. La columna password se almacena como hash argon2. Usar una herramienta de hash argon2 de confianza — no usar MD5 ni bcrypt.
5. Acceso al portal de vendedor perdido — token expirado
Síntoma: Un commission rep dice que su magic-link expiró o perdió el acceso. No puede loguearse al portal de vendedores.
Causa probable: El invitation token expiró (TTL de 7 días). El rep ya consumió el token en otro dispositivo. commission_reps.user_id es null (el rep nunca se vinculó).
Pasos
Paso 1 — Verificar el estado actual del rep
SELECT cr.id, cr.name, cr.email, cr.user_id, cr.status,
u.email AS email_usuario_vinculado,
u.is_active AS usuario_activo
FROM commission_reps cr
LEFT JOIN "user" u ON u.id = cr.user_id
WHERE cr.email = 'rep@ejemplo.com';Paso 2 — Verificar invitaciones pendientes
SELECT id, token, expires_at, used_at
FROM seller_invitations
WHERE commission_rep_id = '<rep_id>'
ORDER BY created_at DESC
LIMIT 5;Paso 3 — Re-invitar al rep
Desde el portal: /admin/commissions/vendedores → click en el rep → "Invite to portal".
Esto invalida automáticamente los tokens pendientes existentes y crea uno nuevo con TTL fresco de 7 días. El email de invitación se envía a commission_reps.email.
Paso 4 — Si el rep ya tiene usuario vinculado (user_id seteado) pero perdió el acceso
El rep puede haber sido desactivado o su sesión puede haber expirado. Verificar:
SELECT is_active, deactivated_at FROM "user" WHERE id = '<user_id>';Si está desactivado:
UPDATE "user"
SET is_active = true, deactivated_at = NULL, updated_at = NOW()
WHERE id = '<user_id>';Luego invalidar sesiones antiguas:
DELETE FROM session WHERE user_id = '<user_id>';Paso 5 — Si necesitas resetear completamente el acceso al portal del rep
Usar revokeAccess() desde el panel admin para desvincular al usuario, luego re-invitar:
/admin/commissions/vendedores→ detalle del rep → "Revoke portal access"- Luego → "Invite to portal" nuevamente
Esto setea commission_reps.user_id = null e invalida todas las invitaciones pendientes.
Verificar: Confirmar que el rep pueda loguearse con el nuevo link de invitación.
Escalación: Si los emails de invitación no están llegando, verificar la configuración del servicio de email y enviar la URL del magic-link directamente al rep por un canal seguro.
6. Generación de statement fallida / números incorrectos
Síntoma: Un commission statement tiene el total incorrecto, o la generación del statement (trpc.commissions.statements.generate) lanzó un error.
Causa probable: Revenue event duplicado o faltante. Settings de comisiones cambiados después de que se cerró el billing period. Mismatch de moneda en la conversión FX. Falló el cascade de asignación.
Pasos
Paso 1 — Inspeccionar el statement que falla
SELECT s.id, s.rep_id, s.status, s.period_start, s.period_end,
s.gross_amount_cents, s.net_amount_cents, s.currency,
cr.name AS nombre_rep
FROM commission_statements s
JOIN commission_reps cr ON cr.id = s.rep_id
WHERE s.id = '<statement_id>';Paso 2 — Inspeccionar los line items
SELECT cli.*, re.description AS descripcion_revenue, re.amount_cents AS monto_revenue
FROM commission_line_items cli
JOIN revenue_events re ON re.id = cli.revenue_event_id
WHERE cli.statement_id = '<statement_id>'
ORDER BY cli.created_at;Paso 3 — Verificar el attribution log
SELECT cal.*
FROM commission_attribution_log cal
WHERE cal.revenue_event_id IN (
SELECT revenue_event_id FROM commission_line_items WHERE statement_id = '<statement_id>'
);El attribution log es inmutable — muestra exactamente qué lógica de asignación se ejecutó y por qué.
Paso 4 — Si falta un revenue event
Verificar si el webhook de factura de Stripe fue procesado:
SELECT * FROM revenue_events
WHERE stripe_invoice_id = '<stripe_invoice_id>';Si falta, reenviar el webhook invoice.paid desde Stripe (ver Runbook #2).
Paso 5 — Si los números son incorrectos por un cambio de settings
Los cambios en commission settings no actualizan retroactivamente los statements. La fecha efectiva de commission_settings determina qué tasas aplican. Si se necesita una corrección retroactiva:
- Borrar el statement incorrecto (si status =
draft):
DELETE FROM commission_statements WHERE id = '<statement_id>' AND status = 'draft';
DELETE FROM commission_line_items WHERE statement_id = '<statement_id>';- Corregir los settings si es necesario.
- Regenerar desde el portal.
Paso 6 — Si se necesita corrección después de que el statement está approved o paid
NO borrar statements aprobados/pagados. En su lugar:
- Crear un statement de ajuste manual con un line item negativo.
- Documentar en
commission_statements.notes.
Verificar: Re-generar el statement y comparar los totales con los valores esperados.
Escalación: Si la lógica de atribución devuelve resultados incorrectos, agregar logging de debug a commission-calculator.ts y ejecutar testCalculation con los inputs específicos del revenue event.
7. Suscripción de Stripe fuera de sincronía
Síntoma: La suscripción de Stripe de un tenant muestra active en Stripe pero suspended (o viceversa) en SipSop. La columna stripe_status no coincide con lo que muestra Stripe.
Causa probable: El job stripe-sync no corrió recientemente (corre cada 6 horas). Se perdió un webhook. Cambio manual de estado en la DB sin la actualización correspondiente en Stripe.
Pasos
Paso 1 — Verificar el estado actual
SELECT id, name, status, stripe_status, stripe_subscription_id
FROM tenants
WHERE id = '<tenant_id>';Paso 2 — Verificar directamente en Stripe
- Stripe Dashboard → Subscriptions → buscar por
stripe_subscription_id. - Anotar el estado real de la suscripción.
Paso 3 — Disparar stripe-sync manualmente
# Agregar a la cola stripe-sync
redis-cli -p 6380 RPUSH "bull:stripe-sync:wait" \
'{"id":"manual-sync","name":"stripe-sync","data":{},"opts":{"attempts":1}}'Monitorear via logs de BullMQ o esperar que el job complete (típicamente < 30 segundos).
Paso 4 — Si stripe-sync no lo soluciona, actualizar manualmente
Hacer coincidir el estado en la DB con lo que muestra Stripe:
UPDATE tenants
SET stripe_status = '<estado_real_stripe>',
status = CASE
WHEN '<estado_real_stripe>' = 'active' THEN 'active'
WHEN '<estado_real_stripe>' = 'past_due' THEN 'past_due'
WHEN '<estado_real_stripe>' IN ('canceled', 'incomplete_expired') THEN 'cancelled'
ELSE status
END,
updated_at = NOW()
WHERE id = '<tenant_id>';Verificar: Recargar /admin/tenants/:id/overview y confirmar que el badge de estado coincide con Stripe.
Escalación: Si la suscripción en Stripe está en un estado que SipSop no reconoce, verificar el event log de Stripe y reenviar el evento relevante.
8. Backup y restore de PostgreSQL
Síntoma: Pérdida de datos, borrado accidental, o escenario de DR que requiere restore.
Backup (producción — Azure Database for PostgreSQL)
Azure Database for PostgreSQL (Flexible Server) realiza backups automáticos:
- Backups completos: semanalmente
- Backups de transaction log: cada 5 minutos
- Retención: 7 días por defecto (configurable hasta 35 días)
Para disparar un backup manual via Azure CLI:
az postgres flexible-server backup create \
--resource-group sipsop-rg \
--name sipsop-postgres \
--backup-name manual-$(date +%Y%m%d%H%M)pg_dump manual (dev local o migración)
# Desde dev (conecta al Docker local en puerto 5434)
pg_dump \
--host localhost \
--port 5434 \
--username sipsop \
--dbname sipsop \
--format custom \
--file sipsop-$(date +%Y%m%d).dump
# Comprimir con gzip para almacenamiento
pg_dump ... --format plain | gzip > sipsop-$(date +%Y%m%d).sql.gzRestore desde pg_dump
# Formato custom
pg_restore \
--host localhost \
--port 5434 \
--username sipsop \
--dbname sipsop \
--clean \
--if-exists \
sipsop-20260101.dump
# Formato SQL plano
psql -h localhost -p 5434 -U sipsop -d sipsop < sipsop-20260101.sqlPoint-in-time restore (Azure — producción)
az postgres flexible-server restore \
--resource-group sipsop-rg \
--name sipsop-postgres-restored \
--source-server sipsop-postgres \
--restore-time "2026-05-10T03:00:00Z"Esto crea un nuevo servidor — no restaurar in-place. Después de validar la integridad de los datos en el servidor restaurado, actualizar DATABASE_URL en Key Vault para apuntar al nuevo servidor.
Después del restore — re-aplicar RLS
Las políticas RLS son a nivel de base de datos. Sobreviven un restore estándar, pero verificar:
pnpm --filter @sipsop/db db:apply-rls-policiesUsá db:apply-rls-policies (solo policies), no db:apply-rls — este último además rota la password de sipsop_app y dejaría a la API sin acceso a la base restaurada.
Verificar: Ejecutar una consulta de prueba con el rol sipsop_app para confirmar que RLS está activo:
SET ROLE sipsop_app;
SET app.current_tenant = 'aaaaaaaa-0000-0000-0000-000000000000';
SELECT COUNT(*) FROM cdr_records; -- Debe retornar 0 (sin registros para tenant ficticio)
RESET ROLE;Escalación: Para restores en producción, notificar a todos los stakeholders antes de comenzar. Restaurar la DB causará downtime. Coordinar con el equipo de Azure si el servidor managed necesita cambiarse.