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.*usanadminProcedure+requirePermission('commissions.manage'). Los del routerseller.*usansellerProcedure(opublicProcedurepara invitaciones). - Errores: se mapean a
TRPCErrorcon 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-DDpara dates,YYYY-MMpara 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)// 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 fallacommissions.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_FOUNDcommissions.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_EXISTScommissions.reps.update
Type: Mutation
Auth: adminProcedure + commissions.manage
Input: { id: uuid, patch: Partial<CreateRepInput> }
Output: CommissionRep actualizado
Errors: NOT_FOUND, EMAIL_EXISTS, REF_CODE_EXISTScommissions.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_FOUNDcommissions.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 nullcommissions.reps.revokeAccess
Type: Mutation
Auth: adminProcedure + commissions.manage
Input: { id: uuid }
Output: { success: true }
Errors: NOT_FOUNDSetea 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_FOUNDcommissions.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_FOUNDNo permite cambiar tenantId.
commissions.assignments.end
Type: Mutation
Auth: adminProcedure + commissions.manage
Input: { id: uuid }
Output: CommissionAssignment con status='ended', endsAt=today
Errors: NOT_FOUNDcommissions.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_PARTICIPANTSTransacció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_IMMUTABLEcommissions.revenueEvents.delete
Type: Mutation
Auth: adminProcedure + commissions.manage
Input: { id: uuid }
Output: { success: true }
Errors: NOT_FOUND, STRIPE_IMMUTABLEcommissions.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.
// 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_FOUNDcommissions.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_FOUNDOverride 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 | nullcommissions.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:
- SELECT FOR UPDATE de la invitación activa.
- Crea usuario en Better Auth con role='seller'.
- Vincula
commission_reps.userId = newUser.id. - 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 autenticadoseller.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 autenticadoseller.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_FOUNDEl 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 entryStripe 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) comparandoline.price.idcon los price IDs deplans. - Crea un
revenue_eventconsourceRef = ${invoice.id}:${line.id}. - Si no hay líneas: crea 1 evento con
component='other'ysourceRef = 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.amountcomponent = '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
| Code | HTTP | Cuándo |
|---|---|---|
NOT_FOUND | 404 | Recurso no existe |
CONFLICT | 409 | Delete con statements existentes, finalize/markPaid con status incorrecto |
FORBIDDEN | 403 | Sin permiso, o intento de mutar evento Stripe |
BAD_REQUEST | 400 | Validación Valibot fallida, cascade sin términos, participantes inválidos, ciclo |
EMAIL_EXISTS | 409 | Email duplicado en commission_reps |
REF_CODE_EXISTS | 409 | Ref code duplicado |
USER_ALREADY_LINKED | 409 | userId ya asignado al rep |
ALREADY_LINKED | 409 | Rep ya tiene userId (al invitar) |
NO_TERMS | 400 | Cascade resolver no resolvió ningún término monetario > 0 |
STRIPE_IMMUTABLE | 403 | Intento de editar/borrar evento de source='stripe_invoice' |
CYCLE | 400 | managerId formaría ciclo en la jerarquía |
INVALID_PARTICIPANTS | 400 | Shares no suman 100, array vacío, o rep duplicado |
INVALID_OR_EXPIRED | 400 | Token de invitación no existe, expiró, o ya fue usado |
INTERNAL | 500 | Error inesperado del servidor |