Skip to content

API Reference — módulo de comisiones

Todos los procedures tRPC y endpoints REST del módulo. Verificado contra el código en apps/api/src/modules/commissions/.


Convenciones

  • Auth gate: todos los procedures del router commissions.* usan adminProcedure + requirePermission('commissions.manage'). Los del router seller.* usan sellerProcedure (o publicProcedure para invitaciones).
  • Errores: se mapean a TRPCError con codes HTTP estándar. Ver Códigos de error.
  • Input: validado con Valibot. Schemas en apps/api/src/modules/commissions/commissions.schema.ts.
  • Formato de fechas: YYYY-MM-DD para dates, YYYY-MM para períodos.
  • Montos: siempre en centavos USD (integers). 1099 = $10.99.

Router commissions.*

commissions.settings

commissions.settings.get

Type:    Query
Auth:    adminProcedure + commissions.manage
Input:   none
Output:  CommissionSettings (singleton row id=1)
ts
// Output shape
{
  id: 1,
  defaultBasis: 'gross' | 'net',
  defaultCurrency: string,           // 'USD'
  stripeFeePercent: string,          // numeric string, e.g. '2.9'
  stripeFeeFixedCents: number,       // 30
  defaultRecurringPercent: string | null,
  defaultRecurringFixedCents: number | null,
  defaultOneTimeAmountCents: number | null,
  oneTimeQualificationMonths: number,
  cronDayOfMonth: number,
  cronHour: number,
  autoFinalize: boolean,
  autoEmailOnFinalize: boolean,
  updatedAt: string,
}

commissions.settings.update

Type:    Mutation
Auth:    adminProcedure + commissions.manage
Input:   Partial<CommissionSettings> (sin id)
Output:  CommissionSettings actualizado
Errors:  BAD_REQUEST si validación falla

commissions.reps

commissions.reps.list

Type:    Query
Auth:    adminProcedure + commissions.manage
Input:   { search?: string, status?: 'active' | 'inactive', managerId?: string | null }
Output:  CommissionRep[]

commissions.reps.get

Type:    Query
Auth:    adminProcedure + commissions.manage
Input:   { id: uuid }
Output:  CommissionRep + manager: CommissionRep | null + directReports: CommissionRep[]
Errors:  NOT_FOUND

commissions.reps.create

Type:    Mutation
Auth:    adminProcedure + commissions.manage
Input:
  {
    name: string,
    email: string,
    phone?: string,
    taxId?: string,
    country?: string,
    paymentMethod?: 'ach' | 'wire' | 'check' | 'paypal' | 'wise' | 'other',
    paymentDetails?: object,
    defaultRecurringPercent?: string,
    defaultRecurringFixedCents?: number,
    defaultOneTimeAmountCents?: number,
    defaultBasis?: 'gross' | 'net',
    refCode?: string,     // si null, se auto-genera del name
    notes?: string,
  }
Output:  CommissionRep creado
Errors:  EMAIL_EXISTS, REF_CODE_EXISTS

commissions.reps.update

Type:    Mutation
Auth:    adminProcedure + commissions.manage
Input:   { id: uuid, patch: Partial<CreateRepInput> }
Output:  CommissionRep actualizado
Errors:  NOT_FOUND, EMAIL_EXISTS, REF_CODE_EXISTS

commissions.reps.delete

Type:    Mutation
Auth:    adminProcedure + commissions.manage
Input:   { id: uuid }
Output:  { success: true }
Errors:  NOT_FOUND, CONFLICT (si tiene statements finalized o paid)

commissions.reps.setManager

Type:    Mutation
Auth:    adminProcedure + commissions.manage
Input:   { id: uuid, managerId: uuid | null }
Output:  CommissionRep actualizado
Errors:  NOT_FOUND, CYCLE (si forma ciclo en la jerarquía)

commissions.reps.setOverride

Type:    Mutation
Auth:    adminProcedure + commissions.manage
Input:   { id: uuid, overridePercent: string }  // numeric string
Output:  CommissionRep actualizado
Errors:  NOT_FOUND

commissions.reps.invite

Type:    Mutation
Auth:    adminProcedure + commissions.manage
Input:   { id: uuid }
Output:  { invitationId: uuid, setupUrl: string, expiresAt: string }
Errors:  NOT_FOUND, ALREADY_LINKED (si ya tiene userId)

Envía email de invitación (fire-and-forget). La respuesta incluye setupUrl para copy/paste si el email falla.

commissions.reps.pendingInvitation

Type:    Query
Auth:    adminProcedure + commissions.manage
Input:   { id: uuid }
Output:  SellerInvitation | null  // la invitación active más reciente, o null

commissions.reps.revokeAccess

Type:    Mutation
Auth:    adminProcedure + commissions.manage
Input:   { id: uuid }
Output:  { success: true }
Errors:  NOT_FOUND

Setea commission_reps.userId = null. No borra el usuario de Better Auth.


commissions.assignments

commissions.assignments.list

Type:    Query
Auth:    adminProcedure + commissions.manage
Input:   { commissionRepId?: uuid, tenantId?: uuid, status?: 'active' | 'ended' }
Output:  CommissionAssignment[]

commissions.assignments.get

Type:    Query
Auth:    adminProcedure + commissions.manage
Input:   { id: uuid }
Output:  CommissionAssignment + participants: CommissionParticipant[]
Errors:  NOT_FOUND

commissions.assignments.create

Type:    Mutation
Auth:    adminProcedure + commissions.manage
Input:
  {
    tenantId: uuid,
    participants: Array<{ commissionRepId: uuid, sharePercent: string, role?: string }>,
    recurringPercent?: string,
    recurringFixedCents?: number,
    oneTimeAmountCents?: number,
    basis?: 'gross' | 'net',
    startsAt: string,         // YYYY-MM-DD
    endsAt?: string,
    notes?: string,
    recurringPercentBase?: string,
    recurringPercentPhones?: string,
    recurringPercentLines?: string,
    recurringPercentOverage?: string,
  }
Output:  CommissionAssignment creado + participants
Errors:  NO_TERMS, INVALID_PARTICIPANTS (shares ≠ 100, array vacío, rep duplicado)

Los términos se resuelven por cascade: input → rep default → global default.

commissions.assignments.update

Type:    Mutation
Auth:    adminProcedure + commissions.manage
Input:   { id: uuid, patch: Partial<CreateAssignmentInput excl. tenantId> }
Output:  CommissionAssignment actualizado
Errors:  NOT_FOUND

No permite cambiar tenantId.

commissions.assignments.end

Type:    Mutation
Auth:    adminProcedure + commissions.manage
Input:   { id: uuid }
Output:  CommissionAssignment con status='ended', endsAt=today
Errors:  NOT_FOUND

commissions.assignments.replaceParticipants

Type:    Mutation
Auth:    adminProcedure + commissions.manage
Input:
  {
    id: uuid,
    participants: Array<{ commissionRepId: uuid, sharePercent: string, role?: string }>
  }
Output:  CommissionParticipant[] (los nuevos)
Errors:  NOT_FOUND, INVALID_PARTICIPANTS

Transacción atómica: DELETE all + INSERT new. El trigger DEFERRABLE valida suma=100 al COMMIT.


commissions.tenantsPicker

Type:    Query
Auth:    adminProcedure + commissions.manage
Input:   none
Output:  Array<{ id: uuid, name: string, slug: string, status: string }>

Lista todos los tenants para usar en comboboxes.


commissions.revenueEvents

commissions.revenueEvents.list

Type:    Query
Auth:    adminProcedure + commissions.manage
Input:
  {
    tenantId?: uuid,
    commissionRepId?: uuid,
    period?: string,           // YYYY-MM exacto
    rangeStart?: string,       // YYYY-MM
    rangeEnd?: string,
    source?: 'stripe_invoice' | 'wire' | 'check' | 'manual' | 'other'
  }
Output:  RevenueEvent[]

commissions.revenueEvents.create

Type:    Mutation
Auth:    adminProcedure + commissions.manage
Input:
  {
    tenantId: uuid,
    period: string,               // YYYY-MM
    originalCurrency: string,     // ISO 4217
    originalAmountCents: number,  // puede ser negativo
    fxRate?: string,              // auto-calculado si no se envía
    source: 'wire' | 'check' | 'manual' | 'other',  // NO permite stripe_invoice
    sourceRef?: string,
    component?: 'base' | 'phones' | 'lines' | 'overage' | 'other',
    notes?: string,
  }
Output:  RevenueEvent creado
Errors:  FORBIDDEN (si source='stripe_invoice')

commissions.revenueEvents.update

Type:    Mutation
Auth:    adminProcedure + commissions.manage
Input:   { id: uuid, patch: Partial<CreateRevenueEventInput> }
Output:  RevenueEvent actualizado
Errors:  NOT_FOUND, STRIPE_IMMUTABLE

commissions.revenueEvents.delete

Type:    Mutation
Auth:    adminProcedure + commissions.manage
Input:   { id: uuid }
Output:  { success: true }
Errors:  NOT_FOUND, STRIPE_IMMUTABLE

commissions.preview

commissions.preview.calculate

Type:    Query
Auth:    adminProcedure + commissions.manage
Input:
  {
    periodStart: string,       // YYYY-MM-DD (primer día del mes)
    periodEnd: string,         // YYYY-MM-DD (último día del mes)
    commissionRepId?: uuid,
    tenantId?: uuid,
  }
Output:  CalculatedStatement[]

Corre el calculator en modo preview (sin persistir). Mismo resultado que generaría generate.

ts
// CalculatedStatement shape
{
  commissionRepId: string,
  currency: string,
  subtotalRecurringCents: number,
  subtotalOneTimeCents: number,
  subtotalOverrideCents: number,
  totalCents: number,
  lineItems: CalculatedLineItem[],
}

commissions.statements

commissions.statements.list

Type:    Query
Auth:    adminProcedure + commissions.manage
Input:   { commissionRepId?: uuid, status?: string, rangeStart?: string, rangeEnd?: string }
Output:  CommissionStatement[]

commissions.statements.get

Type:    Query
Auth:    adminProcedure + commissions.manage
Input:   { id: uuid }
Output:  { statement: CommissionStatement, lineItems: CommissionLineItem[] }
Errors:  NOT_FOUND

commissions.statements.generate

Type:    Mutation
Auth:    adminProcedure + commissions.manage
Input:   { period: string }   // YYYY-MM
Output:  { created: number, replaced: number, skipped: number, statementIds: uuid[] }

commissions.statements.finalize

Type:    Mutation
Auth:    adminProcedure + commissions.manage
Input:   { id: uuid }
Output:  CommissionStatement con status='finalized'
Errors:  NOT_FOUND, CONFLICT (status ≠ 'draft')

commissions.statements.markPaid

Type:    Mutation
Auth:    adminProcedure + commissions.manage
Input:
  {
    id: uuid,
    paymentMethod: 'ach' | 'wire' | 'check' | 'paypal' | 'wise' | 'other',
    paymentReference?: string,
    paymentNotes?: string,
  }
Output:  CommissionStatement con status='paid'
Errors:  NOT_FOUND, CONFLICT (status ≠ 'finalized')

commissions.statements.delete

Type:    Mutation
Auth:    adminProcedure + commissions.manage
Input:   { id: uuid }
Output:  { success: true }
Errors:  NOT_FOUND, CONFLICT (status ≠ 'draft')

commissions.overview

commissions.overview.kpis

Type:    Query
Auth:    adminProcedure + commissions.manage
Input:   none
Output:
  {
    period: string,               // YYYY-MM actual
    ingresosDelMes: number,       // sum de revenue_events.amountUsdCents del período actual
    statementsPendientes: number, // count de statements en status='draft'
    repCount: number,             // reps activos
    assignmentCount: number,      // asignaciones activas
  }

commissions.leads

commissions.leads.setRep

Type:    Mutation
Auth:    adminProcedure + commissions.manage
Input:   { id: uuid, commissionRepId: uuid | null }
Output:  Lead actualizado
Errors:  NOT_FOUND

Override manual de la atribución de un lead. Crea entry en commission_attribution_log con action='lead_admin_set'.


commissions.attribution

commissions.attribution.list

Type:    Query
Auth:    adminProcedure + commissions.manage
Input:
  {
    leadId?: uuid,
    tenantId?: uuid,
    action?: string,
    rangeStart?: string,   // ISO date
    rangeEnd?: string,
  }
Output:  CommissionAttributionLog[]

commissions.me

Procedures accesibles a cualquier admin_msp, sin requerir commissions.manage. Solo devuelven datos del rep vinculado al usuario autenticado.

commissions.me.rep

Type:    Query
Auth:    adminProcedure (sin commissions.manage)
Input:   none
Output:  CommissionRep | null

commissions.me.statements

Type:    Query
Auth:    adminProcedure (sin commissions.manage)
Input:   none
Output:  CommissionStatement[]

commissions.me.assignments

Type:    Query
Auth:    adminProcedure (sin commissions.manage)
Input:   none
Output:  CommissionAssignment[]   // solo status='active'

Router seller.*

seller.invitations — publicProcedure (sin auth)

seller.invitations.preview

Type:    Query
Auth:    publicProcedure (no requiere sesión)
Input:   { token: string }
Output:  { valid: boolean, email?: string, repName?: string }

Si el token no existe, expiró, o ya fue usado: { valid: false }.

seller.invitations.consume

Type:    Mutation
Auth:    publicProcedure (no requiere sesión)
Input:   { token: string, password: string }
Output:  { success: true, userId: string, redirectTo: '/seller/dashboard' }
Errors:  INVALID_OR_EXPIRED, INTERNAL (si Better Auth falla al crear usuario)

Transacción atómica:

  1. SELECT FOR UPDATE de la invitación activa.
  2. Crea usuario en Better Auth con role='seller'.
  3. Vincula commission_reps.userId = newUser.id.
  4. Marca invitación como usada.

seller.me

Type:    Query
Auth:    sellerProcedure
Input:   none
Output:  { rep: CommissionRep }

seller.statements

seller.statements.list

Type:    Query
Auth:    sellerProcedure
Input:
  {
    status?: 'draft' | 'finalized' | 'paid',
    rangeStart?: string,   // YYYY-MM-DD
    rangeEnd?: string,
  }
Output:  CommissionStatement[]  // solo los del rep autenticado

seller.statements.get

Type:    Query
Auth:    sellerProcedure
Input:   { id: uuid }
Output:  { statement: CommissionStatement, lineItems: CommissionLineItem[] }
Errors:  NOT_FOUND (también si el statement existe pero no es del rep autenticado)

seller.assignments

seller.assignments.list

Type:    Query
Auth:    sellerProcedure
Input:   none
Output:  Array<CommissionAssignment & { role: string, sharePercent: string }>
         // solo asignaciones donde el rep es participant, status='active'

seller.leads

seller.leads.list

Type:    Query
Auth:    sellerProcedure
Input:   none
Output:  Lead[]  // todos los leads con commissionRepId del rep autenticado

seller.kpis

Type:    Query
Auth:    sellerProcedure
Input:   none
Output:
  {
    earnedThisMonth: number,     // totalCents del statement del período actual
    pendingStatements: number,   // count de statements draft o finalized (no pagados)
    leadsTotal: number,
    leadsConverted: number,      // leads con status='converted'
  }

REST endpoints

GET /api/v1/admin/commissions/preview/pdf

Genera un PDF de preview sin persistir. Corre el calculator en tiempo real.

Auth:    sesión activa + role='admin_msp' + commissions.manage
Query params:
  periodStart: string (YYYY-MM-DD, requerido)
  periodEnd: string (YYYY-MM-DD, requerido)
  commissionRepId: uuid (opcional)
  tenantId: uuid (opcional)
Response:
  Content-Type: application/pdf
  Content-Disposition: attachment; filename="preview-{email}-{periodStart}.pdf"
Errors:
  401 UNAUTHORIZED
  403 FORBIDDEN
  400 MISSING_PARAMS
  404 NO_DATA (no hay comisiones para los filtros dados)

El PDF incluye badge "DRAFT" prominente.


GET /api/v1/admin/commissions/statements/:id/pdf

Genera PDF de un statement persistido.

Auth:    sesión activa + role='admin_msp' + commissions.manage
Params:
  id: uuid (statement id)
Response:
  Content-Type: application/pdf
  Content-Disposition: attachment; filename="statement-{email}-{periodStart}.pdf"
Errors:
  401 UNAUTHORIZED
  403 FORBIDDEN
  404 NOT_FOUND

El badge "DRAFT" aparece solo si el statement tiene status='draft'.


GET /api/v1/seller/statements/:id/pdf

Genera PDF de un statement del seller autenticado. Verifica ownership.

Auth:    sesión activa + role='seller' + rep activo
Params:
  id: uuid (statement id)
Response:
  Content-Type: application/pdf
  Content-Disposition: attachment; filename="statement-{email}-{periodStart}.pdf"
Errors:
  401 UNAUTHORIZED
  403 FORBIDDEN (no es seller, o rep inactivo)
  404 NOT_FOUND (statement no existe o no es del rep autenticado)

El ownership check siempre retorna 404 (nunca 403) para no exponer si el statement existe.


POST /api/v1/leads — side effect de comisiones

Endpoint público existente extendido con refCode:

Auth:    public (sin auth)
Body:    { name, email, phone?, company?, message?, refCode?: string }
Side effect de comisiones:
  Si refCode presente y resuelve a un rep activo:
    - leads.commissionRepId = rep.id
    - INSERT commission_attribution_log (action='lead_ref_code_set')
  Si refCode presente pero no resuelve:
    - leads.refCode = refCode, commissionRepId = null
    - INSERT log (action='lead_ref_code_set', notes='Unknown ref code: ...')
  Si refCode ausente:
    - Sin cambios en comisiones, sin log entry

Stripe webhook side-effects

El webhook existente POST /api/v1/webhooks/stripe maneja:

invoice.paid — crea revenue events:

  • Descompone el invoice en N líneas de invoice.lines.data.
  • Por cada línea: clasifica el componente (base, phones, lines, overage, other) comparando line.price.id con los price IDs de plans.
  • Crea un revenue_event con sourceRef = ${invoice.id}:${line.id}.
  • Si no hay líneas: crea 1 evento con component='other' y sourceRef = invoice.id.
  • FX rate: si la moneda no es USD, fetcha desde exchangerate.host (cache Redis 24h).

charge.refunded — crea un evento negativo:

  • originalAmountCents = -refund.amount
  • component = 'other'
  • sourceRef = ${charge.id}.refund

Los fallos del side-effect no bloquean el 200 a Stripe. Se encolan en BullMQ commissions-stripe-retry.


Códigos de error

CodeHTTPCuándo
NOT_FOUND404Recurso no existe
CONFLICT409Delete con statements existentes, finalize/markPaid con status incorrecto
FORBIDDEN403Sin permiso, o intento de mutar evento Stripe
BAD_REQUEST400Validación Valibot fallida, cascade sin términos, participantes inválidos, ciclo
EMAIL_EXISTS409Email duplicado en commission_reps
REF_CODE_EXISTS409Ref code duplicado
USER_ALREADY_LINKED409userId ya asignado al rep
ALREADY_LINKED409Rep ya tiene userId (al invitar)
NO_TERMS400Cascade resolver no resolvió ningún término monetario > 0
STRIPE_IMMUTABLE403Intento de editar/borrar evento de source='stripe_invoice'
CYCLE400managerId formaría ciclo en la jerarquía
INVALID_PARTICIPANTS400Shares no suman 100, array vacío, o rep duplicado
INVALID_OR_EXPIRED400Token de invitación no existe, expiró, o ya fue usado
INTERNAL500Error inesperado del servidor

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