Skip to content

FAQ y troubleshooting

Preguntas frecuentes organizadas por área, y guías de diagnóstico para los problemas más comunes.


Atribución

¿Por qué first-touch y no last-touch?

First-touch incentiva a los reps a traer leads en la etapa temprana del ciclo de ventas, cuando el costo de adquisición es más alto. Un SDR que hace 10 llamadas frías merece crédito incluso si el cierre lo maneja un AE semanas después. El split de participants (70/30, etc.) permite reconocer ambas contribuciones sin disputar la atribución.

Last-touch favorecería siempre al AE que cierra, desincentivando la prospección. En el modelo de Sopinf, los SDRs prospectarían menos si supieran que perderían el crédito al cierre.

¿Cómo cambio la atribución de un lead?

Si el lead aún no se convirtió en tenant:

  • Portal admin → /admin/commissions/atribucion para ver el log actual.
  • Usar commissions.leads.setRep(leadId, newRepId) — crea un log entry lead_admin_set.

Si el lead ya se convirtió y hay una asignación activa:

  • /admin/commissions/asignaciones → editar la asignación → replaceParticipants.
  • Cada participante cambiado genera un log entry admin_reassigned.

Los statements ya finalized o paid no se ven afectados. Solo los futuros.

¿Qué pasa si el cookie expira?

El cookie sipsop_ref tiene Max-Age=7776000 (90 días). Si el prospecto visita el landing después de que el cookie expiró y no tiene el querystring ?ref= activo, el form se envía sin refCode y el lead se crea sin atribución.

Si el prospecto llega de nuevo con el link ?ref=maria-gonzalez, se establece un nuevo cookie (first-touch aplica si no había cookie previo). Si ya hay un cookie de una visita anterior, no se sobreescribe — el primer toque sigue ganando.

¿El ref code es case-sensitive?

Sí. El lookup en commission_reps WHERE ref_code = input es case-sensitive en Postgres por default. El sistema genera siempre ref codes en minúsculas (maria-gonzalez, no Maria-Gonzalez). Si un rep compartió un link con mayúsculas, el match puede fallar. Solución: el rep debe compartir siempre el link generado por el sistema (copiado desde el portal admin).


Cálculo

¿Cómo se prorratea el net basis por componente?

El motor calcula primero el totalGross (suma de todos los revenue events del período) y el totalNet (descontando Stripe fees). Luego, por componente:

componentNet(c) = round(eventsByComponent(c) × totalNet / totalGross)

Es decir, la proporción de net asignada a cada componente refleja su peso en el gross total. No se descuentan Stripe fees por componente individualmente — el descuento se hace sobre el total y se prorratea.

¿Qué pasa con eventos en componente NULL (legacy)?

Eventos creados antes de F-CMS-3 tienen component = NULL. El calculator los trata como fallback al recurringPercent global del assignment (no al % por componente). Si el assignment está en modo breakdown (algún % por componente seteado), los eventos NULL contribuyen al revenue total pero su comisión usa el % global. Esto preserva backward compat sin pérdida de datos.

¿Override se aplica antes o después del split?

Después. El flujo es:

  1. Calcular la comisión total de la asignación para el período.
  2. Multiplicar por el share del participant (ej: 70%).
  3. Ese monto (post-split) es la comisión del IC.
  4. El override del manager se aplica sobre ese monto post-split.

Ejemplo: asignación genera $100 total. María tiene 70% share → $70. Carlos tiene 10% override → $7 (10% de $70, no de $100).

¿El one-time se prorratea por share?

Sí. El oneTimeAmountCents de la asignación es el total del bono. Si hay varios participants, cada uno recibe su sharePercent del bono. Con $200 de bono, María al 70% recibe $140 y Pablo al 30% recibe $60.

Los overrides de managers también se calculan sobre el bono prorateado del IC, igual que en recurring.

¿Qué pasa con los refunds?

Los refunds son revenue_events con originalAmountCents negativo. El motor los suma al neto del período — si un tenant pagó $118 y hubo un refund de $30, el revenue neto del período es $88 y la comisión se calcula sobre eso.

Si el refund llega en un período posterior al statement ya finalizado, el evento se inserta en su período original pero el statement de ese período ya está en finalized y no se toca. El impacto del refund aparece en el siguiente período como revenue negativo neto.


Statements

¿Por qué un statement tiene status 'draft'?

El cron (o el admin manual) generó el statement pero aún no fue revisado ni finalizado. El monto en un draft puede cambiar si:

  • Llegan más revenue events para ese período (ej: webhook Stripe tardío).
  • El admin regenera el período (statements.generate reemplaza el draft).
  • El admin cambia los participants de una asignación.

Un draft es una propuesta. La fuente de verdad se establece al finalize.

¿Puedo borrar un statement finalizado?

No. Solo los statements en draft se pueden borrar. Los finalized y paid son inmutables. Si hay un error en un statement finalizado, la corrección se hace con un revenue event compensatorio en el siguiente período.

¿Qué pasa si regenero un mes ya finalizado?

materializeStatementsForPeriod siempre skipea statements en finalized o paid para ese período. Solo reemplaza draft. Si todos los statements del mes ya están finalizados, la operación retorna { created: 0, replaced: 0, skipped: N }.

¿Cómo manejo un refund retroactivo?

Si Stripe emite un refund que corresponde a un período cuyo statement ya está finalized:

  1. El webhook crea el revenue_event negativo correctamente en el período original.
  2. El statement de ese período NO se modifica (está finalizado).
  3. El impacto se refleja en el siguiente período: el revenue neto del período original (período del refund) es menor, y el motor lo considera en el siguiente cálculo. Efectivamente, la deducción aparece en el statement del mes donde el cron corre.

Si querés reflejar el impacto manualmente de forma inmediata, podés crear un revenue event manual negativo en el período actual.


Portal vendedor

¿El vendedor puede ver datos de otros vendedores?

No. El sellerProcedure inyecta el commissionRep del usuario autenticado en el contexto, y todas las queries filtran estrictamente por ese repId. No hay manera de ver statements de otro rep desde el portal seller. El ownership check en el PDF endpoint también previene acceso cruzado.

¿Qué pasa si lo deshabilito mid-período?

Si el admin setea commission_reps.status = 'inactive' o revoca el acceso (userId = null):

  • El sellerProcedure rechaza el próximo request con FORBIDDEN.
  • Los statements existentes (draft, finalized, paid) siguen existiendo en la DB — el admin puede verlos, el seller no.
  • Si se re-invita al rep, puede acceder de nuevo a su historial completo.

Las comisiones acumuladas hasta el momento de la desactivación no desaparecen — el rep tiene derecho a ellas si ya se generaron los statements.

¿Cómo reseteo su password?

Opción actual: el admin usa revokeAccess (setea userId = null) y luego re-invita. El seller crea una nueva contraseña al consumir el nuevo token. Si Better Auth tiene flujo de password reset habilitado en el proyecto, también puede usarse ese camino.

¿Puede el vendedor cambiar su email?

No desde el portal. El email es el del usuario de Better Auth y no está expuesto para edición en el portal seller. Para cambiarlo: el admin actualiza el commission_reps.email, revoca acceso, y re-invita (el nuevo token usará el email actualizado).


Troubleshooting

"Mi statement no aparece"

Checklist:

  1. ¿El período fue generado? → /admin/commissions/statements y buscar por período y rep.
  2. ¿El rep tenía asignaciones activas en ese período? → verificar startsAt / endsAt en la asignación.
  3. ¿Hay revenue events para el tenant en ese período? → /admin/commissions/ingresos con filtro por tenant + período.
  4. ¿El totalCents calculado es > 0? → Usar commissions.preview.calculate para ese período y rep. Si retorna [] o total=0, el calculator no encontró comisión.
  5. ¿La suma de shares de los participants es 100? → Si no, el trigger DB habría rechazado la asignación al crearla, pero revisá de todas formas.

"El override no se está calculando"

Debug steps:

  1. Verificar que el manager tiene overridePercent > 0 en su perfil.
  2. Verificar que el IC tiene managerId apuntando al manager correcto.
  3. Correr commissions.preview.calculate para el período — revisar si hay line items kind='override' en el output.
  4. Si el IC tuvo totalCents = 0 ese período, no hay base para calcular override.
  5. Si hay ciclo en la jerarquía (no debería, el trigger lo previene), el walk TS hace break y loguea CYCLE_DETECTED. Revisar los logs del API.
  6. Verificar que el manager también tiene una asignación activa para poder tener un statement — no: el override genera line items en el statement del manager aunque el manager no tenga asignación propia. El statement se crea igual.

"El email de invitación no llegó"

  1. La invitación se envía fire-and-forget. Si el envío falló, hay un error en los logs pero la mutación tuvo éxito.
  2. La respuesta de commissions.reps.invite incluye setupUrl para copy/paste. El admin puede enviarlo manualmente por otro canal.
  3. Para verificar si la invitación está activa: commissions.reps.pendingInvitation({ id: repId }).
  4. Verificar la variable de entorno del servicio de email (Resend). Si no está configurada, el servicio solo loguea el link.

"Stripe webhook no creó revenue events"

  1. Verificar los logs del API para errores en el handler invoice.paid.
  2. Revisar la cola BullMQ commissions-stripe-retry — si hay jobs ahí, el side-effect falló y está en retry.
  3. El tenantId se resuelve vía stripe_customer_id en la tabla tenants. Si el customer de Stripe no está vinculado a ningún tenant, el handler loguea warning y skip. Verificar que el tenant tiene stripeCustomerId correcto.
  4. Si el problema es FX (moneda no-USD), la API de exchangerate.host puede estar caída o el cache de Redis expiró. Los eventos de retry los reintentan hasta 5 veces con backoff exponencial.
  5. Como último recurso: crear el revenue event manualmente vía commissions.revenueEvents.create con el monto y período correctos.

"Cycle detected al asignar manager"

El DB trigger trg_no_manager_cycle rechazó el UPDATE porque formaría un ciclo. Posibles causas:

  • El rep A quiere tener como manager al rep B, pero B ya tiene como manager a A (directo o indirecto).
  • El rep intenta asignarse como su propio manager.

Para resolver: revisar la cadena actual con commissions.reps.get (incluye manager y directReports). Elegir un manager que esté en una rama separada del árbol.

"INVALID_PARTICIPANTS al crear/editar asignación"

El error puede ser por:

  • Shares no suman exactamente 100. Revisar la suma en el drawer (el live validator lo muestra).
  • Array vacío de participants (al menos 1 es requerido).
  • Rep duplicado en el array (el mismo rep aparece dos veces).
  • sharePercent = 0 en algún participant (el CHECK constraint lo rechaza).

El trigger DEFERRABLE en DB valida al final de la transacción, así que el error puede llegar como error Postgres en lugar del error TRPC INVALID_PARTICIPANTS. Ambos son equivalentes en significado.

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