Skip to content

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

  1. Abrir Stripe Dashboard → Customers → buscar por stripeCustomerId (visible en /admin/tenants/:id/overview).
  2. Ver el estado de la suscripción y el historial de facturas.
  3. Si hay una factura fallida: hacer click → "Retry payment" (botón en el dashboard de Stripe).
  4. 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

sql
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:

bash
# 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

sql
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

  1. Stripe Dashboard → Developers → Webhooks → click en tu endpoint.
  2. Revisar el tab "Failed attempts". Leer el cuerpo de la respuesta de error.
  3. Anotar los IDs de eventos de deliveries fallidas.

Paso 2 — Verificar logs de la API

bash
# 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

  1. En Stripe Dashboard → Webhooks → endpoint → hacer click en un evento fallido.
  2. Click "Resend" (arriba a la derecha). Stripe reintentará la delivery una vez.
  3. Para múltiples eventos, usar Stripe CLI:
bash
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 por stripe_invoice_id, solo actualiza si status != paid
  • subscription.updated: actualiza tenants.stripe_status incondicionalmente (seguro)

Paso 5 — Si la verificación de firma está fallando

Verificar que la variable de entorno esté configurada correctamente:

bash
# Verificar en Azure Key Vault
az keyvault secret show --vault-name sipsop-kv --name sipsop-stripe-webhook-secret

Comparar con el signing secret que muestra Stripe Dashboard → Webhooks → endpoint → "Signing secret".

Verificar: Después del reenvío, verificar tabla invoices:

sql
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

bash
# 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

bash
redis-cli -p 6380 LLEN bull:cdr-sync:failed
redis-cli -p 6380 LRANGE bull:cdr-sync:failed 0 4

Paso 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

bash
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:

bash
# Azure Container Apps
az containerapp revision restart --name sipsop-worker --resource-group sipsop-rg

Paso 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:

sql
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

sql
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

sql
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):

bash
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

sql
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

sql
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

sql
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:

sql
SELECT is_active, deactivated_at FROM "user" WHERE id = '<user_id>';

Si está desactivado:

sql
UPDATE "user"
SET is_active = true, deactivated_at = NULL, updated_at = NOW()
WHERE id = '<user_id>';

Luego invalidar sesiones antiguas:

sql
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:

  1. /admin/commissions/vendedores → detalle del rep → "Revoke portal access"
  2. 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

sql
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

sql
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

sql
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:

sql
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:

  1. Borrar el statement incorrecto (si status = draft):
sql
DELETE FROM commission_statements WHERE id = '<statement_id>' AND status = 'draft';
DELETE FROM commission_line_items WHERE statement_id = '<statement_id>';
  1. Corregir los settings si es necesario.
  2. 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:

  1. Crear un statement de ajuste manual con un line item negativo.
  2. 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

sql
SELECT id, name, status, stripe_status, stripe_subscription_id
FROM tenants
WHERE id = '<tenant_id>';

Paso 2 — Verificar directamente en Stripe

  1. Stripe Dashboard → Subscriptions → buscar por stripe_subscription_id.
  2. Anotar el estado real de la suscripción.

Paso 3 — Disparar stripe-sync manualmente

bash
# 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:

sql
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:

bash
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)

bash
# 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.gz

Restore desde pg_dump

bash
# 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.sql

Point-in-time restore (Azure — producción)

bash
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:

bash
pnpm --filter @sipsop/db db:apply-rls-policies

Usá 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:

sql
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.

Documentación de SipSop. Producto operado por Sopinf Tech LLC.