Pagos a Creadores
¿Qué es?
Section titled “¿Qué es?”El módulo CreatorEarningsModule implementa el sistema de monetización para los creadores de contenido de NappAI Fluency. Es un modelo de revenue share basado en pool mensual, no en comisión por venta individual: cada mes se destina un porcentaje del ingreso neto de la plataforma a un fondo común que se reparte entre los creadores elegibles en proporción a su rendimiento relativo.
Archivos clave:
apps/api/src/modules/creator-earnings/— módulo NestJS completoapps/web/src/pages/creator/CreatorEarningsPage.tsx— dashboard del creadorapps/web/src/pages/admin/AdminCreatorPayoutsPage.tsx— panel admin de payoutsapps/web/src/components/creator/EarningsPreviewCard.tsx— tarjeta resumen en Creator Studioapps/web/src/store/api/creatorEarningsApi.ts— slice RTK Querypackages/shared-types/src/creator-earnings.ts— tipos TypeScript compartidospackages/shared-schemas/src/creator-earnings.schemas.ts— schemas Zod de validación
Modelo de negocio
Section titled “Modelo de negocio”Revenue share basado en pool
Section titled “Revenue share basado en pool”El sistema no asigna un porcentaje fijo por venta. En su lugar:
- Al cierre de cada mes, el SUPER_ADMIN introduce los datos financieros del período (ingresos brutos, comisiones Stripe, reembolsos) en el panel admin.
- El sistema calcula el ingreso neto y sobre él aplica el
poolPercentage(por defecto 25%) para obtener elcreatorPoolAmount. - Ese importe se reparte entre todos los creadores elegibles en proporción a su score de rendimiento relativo.
grossRevenue - stripeFeesTotal - refundsTotal = netRevenuenetRevenue × poolPercentage = creatorPoolAmountConfiguración por período
Section titled “Configuración por período”Cada mes tiene su propio PlatformRevenueConfig con parámetros ajustables antes del cierre:
| Campo | Valor por defecto | Descripción |
|---|---|---|
poolPercentage | 0.25 (25%) | Fracción del ingreso neto que va al pool de creadores |
minCompletionsForEligibility | 5 | Mínimo de completados en el mes para ser elegible |
minReviewsForRating | 3 | Mínimo de reseñas en una ruta para que su rating cuente en el score |
Criterios de elegibilidad
Section titled “Criterios de elegibilidad”Un creador es elegible para un período si cumple las tres condiciones:
- Perfil no suspendido (
suspendedAt IS NULL) - Al menos 1 ruta publicada (
status = PUBLISHED) - Al menos
minCompletionsForEligibilitycompletados en el período (por defecto 5)
Si no cumple alguna condición, se almacena el motivo de inelegibilidad:
ineligibilityReason | Causa |
|---|---|
PROFILE_SUSPENDED | El perfil del creador está suspendido |
NO_PUBLISHED_PATHS | No tiene rutas en estado PUBLISHED |
INSUFFICIENT_COMPLETIONS | Completados del período < umbral mínimo |
Fórmula de cálculo
Section titled “Fórmula de cálculo”Score individual
Section titled “Score individual”Cada creador elegible obtiene un score compuesto de tres factores:
score = completions × 0.5 + minutes × 0.3 + weightedRating × 0.2Donde:
completions— número de lecciones completadas en el período (de sus rutas publicadas)minutes— minutos consumidos (aprox.:completions × 15)weightedRating— rating medio ponderado de rutas conreviewCount >= minReviewsForRating
Participación en el pool
Section titled “Participación en el pool”participationPct = score / totalScore (suma de scores de todos los elegibles)grossAmountEur = creatorPoolAmount × participationPctDesglose por ruta (CreatorEarningLine)
Section titled “Desglose por ruta (CreatorEarningLine)”Para cada ruta con completados > 0 se calcula su contribución dentro del earning del creador:
pathScore = pathCompletions × 0.5 + pathMinutes × 0.3 + pathAvgRating × 0.2contributionPct = pathScore / creatorScorepathGrossEur = grossAmountEur × contributionPctDiagrama de flujo completo
Section titled “Diagrama de flujo completo”flowchart TD subgraph Mensual["Ciclo Mensual"] A["Suscripciones / pagos de alumnos\n(Stripe → PaymentsModule)"] --> B["Ingresos brutos\nacumulados"] B --> C["SUPER_ADMIN introduce\ngrossRevenue, stripeFees, refunds\nen /admin/creator-payouts"] C --> D["Sistema calcula\nnetRevenue y creatorPoolAmount\n(poolPct = 25%)"] end
subgraph Diario["Cron Diario — 03:15h"] E["EarningsPreviewJob\nearnings-calculator"] --> F["Calcula score y estimado\npor creador (datos parciales)"] F --> G["Upsert CreatorEarningPreview\n(isEligibleSoFar, estimatedGrossEur)"] end
subgraph Cierre["Cierre de Período — día 2 del mes, 06:00h"] H["EarningsClosureJob\nearnings-closure"] --> I["Calcula scores finales\ndel mes cerrado"] I --> J["Upsert CreatorMonthlyEarning\n(paymentStatus = PENDING_REVIEW)"] J --> K["Crea CreatorEarningLine\npor ruta"] K --> L["Bloquea PlatformRevenueConfig\n(isLocked = true)"] end
subgraph Pago["Proceso de Pago Manual"] M["SUPER_ADMIN revisa earnings\nen /admin/creator-payouts"] --> N["Cambia paymentStatus\nPENDING → APPROVED → PAID"] N --> O["Exporta CSV para Contabilidad"] O --> P["Pago manual por\nel equipo de Contabilidad"] end
subgraph Creator["Panel del Creador"] Q["GET /creator/me/earnings/preview\n(datos del mes en curso)"] R["GET /creator/me/earnings\n(historial paginado)"] end
D --> E D --> H G --> Q L --> R L --> MCron jobs
Section titled “Cron jobs”EarningsPreviewJob — earnings-calculator
Section titled “EarningsPreviewJob — earnings-calculator”Cron: 15 3 * * * (03:15 UTC cada día)
Calcula estimaciones parciales del mes en curso para todos los creadores públicos. Los datos son aproximados porque el período no ha cerrado. El creador ve estos estimados en su panel con el disclaimer apropiado.
Los resultados se materializan en CreatorEarningPreview (un registro por creador, upsert diario).
EarningsClosureJob — earnings-closure
Section titled “EarningsClosureJob — earnings-closure”Cron: 0 6 2 * * (06:00 UTC el día 2 de cada mes)
Procesa el mes anterior de forma definitiva:
- Calcula métricas reales del período cerrado
- Crea/actualiza
CreatorMonthlyEarningconpaymentStatus = PENDING_REVIEW - Crea
CreatorEarningLinepor ruta (detalle del desglose) - Bloquea (
isLocked = true) laPlatformRevenueConfigdel período
Si el período ya está bloqueado, el job lo detecta y aborta sin sobrescribir datos.
Ambos jobs están registrados en ScheduledJobsHandlerRegistry y se pueden activar/desactivar o ejecutar manualmente desde /admin/platform/jobs.
Endpoints REST
Section titled “Endpoints REST”Creador (CONTENT_ADMIN, SUPER_ADMIN)
Section titled “Creador (CONTENT_ADMIN, SUPER_ADMIN)”/api/creator/me/earnings/preview JWT (CONTENT_ADMIN) Preview del mes en curso. Requiere haber aceptado los términos del programa (403 EARNINGS_TERMS_NOT_ACCEPTED si no). /api/creator/me/earnings JWT (CONTENT_ADMIN) Historial paginado de earnings cerrados. Parámetros: page, limit (default 12). /api/creator/me/earnings/:period JWT (CONTENT_ADMIN) Detalle de un período específico (YYYY-MM) con líneas por ruta. /api/creator/me/payment-data-note JWT (CONTENT_ADMIN) Actualiza la nota de datos fiscales (NIF/NIE/CIF) visible solo para Contabilidad. Rechaza IBANs o números de 16 dígitos. /api/creator/me/accept-earnings-terms JWT (CONTENT_ADMIN) Acepta los términos del programa de ingresos. Necesario para ver datos del panel. Admin (SUPER_ADMIN)
Section titled “Admin (SUPER_ADMIN)”/api/admin/creator-payouts/periods JWT + SUPER_ADMIN Lista todos los períodos con su configuración y estado de bloqueo. /api/admin/creator-payouts/periods/:periodMonth JWT + SUPER_ADMIN Detalle completo de un período: config, earnings de todos los creadores y resumen financiero. /api/admin/creator-payouts/periods/:periodMonth JWT + SUPER_ADMIN Actualiza la config del período (grossRevenue, stripeFees, refunds, poolPercentage). Calcula netRevenue y creatorPoolAmount automáticamente. /api/admin/creator-payouts/periods/:periodMonth/close JWT + SUPER_ADMIN Cierre manual de un período (alternativa al cron job automático). /api/admin/creator-payouts/periods/:periodMonth/unlock JWT + SUPER_ADMIN Desbloquea un período cerrado. Requiere motivo obligatorio; el motivo queda registrado en adminNotes. /api/admin/creator-payouts/earnings/:earningId/payment-status JWT + SUPER_ADMIN Cambia el estado de pago de un earning individual (PENDING_REVIEW → IN_REVIEW → APPROVED → PAID). /api/admin/creator-payouts/periods/:periodMonth/export JWT + SUPER_ADMIN Exporta el período como CSV. Marca exportedAt en cada earning. /api/admin/creator-payouts/audit-log JWT + SUPER_ADMIN Historial de cambios de estado de pagos para un período (query param: periodMonth). /api/admin/creator-payouts/jobs/run-preview JWT + SUPER_ADMIN Lanza manualmente el job de preview del mes en curso. Público (sin auth)
Section titled “Público (sin auth)”/api/public/creator-earnings/stats Estadísticas agregadas: ganancias medias del top creador, total pagado histórico, creadores activos y poolPercentage actual. Usada en páginas de marketing. Modelo en base de datos
Section titled “Modelo en base de datos”PlatformRevenueConfig
Section titled “PlatformRevenueConfig”Configuración financiera de cada período mensual. Un registro por mes.
| Campo | Tipo | Descripción |
|---|---|---|
periodMonth | String (UNIQUE) | Formato YYYY-MM |
poolPercentage | Float | Fracción del neto para creadores (0.25 = 25%) |
minCompletionsForEligibility | Int | Mínimo de completados para ser elegible (default 5) |
minReviewsForRating | Int | Mínimo de reseñas para que el rating cuente (default 3) |
grossRevenue | Float? | Ingresos brutos del período (entrada manual del admin) |
stripeFeesTotal | Float? | Comisiones Stripe del período (entrada manual) |
refundsTotal | Float? | Reembolsos del período (entrada manual) |
netRevenue | Float? | Calculado: gross - fees - refunds |
creatorPoolAmount | Float? | Calculado: netRevenue × poolPercentage |
isLocked | Boolean | true tras el cierre definitivo del período |
lockedAt | DateTime? | Timestamp del cierre |
lockedBy | String? | userId del admin o 'system' (cron) |
adminNotes | Text? | Notas del admin; los desbloqueos quedan registrados aquí |
CreatorMonthlyEarning
Section titled “CreatorMonthlyEarning”Un registro por creador por período. Se crea en el cierre del mes.
| Campo | Tipo | Descripción |
|---|---|---|
creatorId | String | FK a CreatorProfile |
configId | String | FK a PlatformRevenueConfig |
periodMonth | String | Formato YYYY-MM |
completionsCount | Int | Total de lecciones completadas en el período |
minutesConsumed | Int | Minutos (aprox.: completions × 15) |
weightedRating | Float | Rating ponderado entre rutas con suficientes reseñas |
reviewsCount | Int | Total de reseñas consideradas |
rawScore | Float | Score calculado: c×0.5 + m×0.3 + r×0.2 |
participationPct | Float | rawScore / totalScoreEligibles |
grossAmountEur | Float | creatorPoolAmount × participationPct |
isEligible | Boolean | Si cumplió todos los criterios de elegibilidad |
ineligibilityReason | String? | Causa de inelegibilidad |
paymentStatus | CreatorPaymentStatus | Estado del pago (ver tabla abajo) |
paymentNotes | Text? | Notas del admin sobre el pago |
paymentStatusUpdatedBy | String? | userId del admin que cambió el estado |
exportedAt | DateTime? | Timestamp cuando se exportó a CSV |
Índice único: (creatorId, periodMonth)
CreatorEarningLine
Section titled “CreatorEarningLine”Detalle del earning de un creador desglosado por ruta. Cascade delete con CreatorMonthlyEarning.
| Campo | Tipo | Descripción |
|---|---|---|
earningId | String | FK a CreatorMonthlyEarning |
pathId | String | FK a Path |
pathTitle / pathSlug | String | Desnormalizados para historial estable |
completionsCount | Int | Completados del período en esta ruta |
minutesConsumed | Int | Minutos en esta ruta |
avgRating | Float? | Rating medio de la ruta |
contributionPct | Float | Fracción del earning total del creador |
grossAmountEur | Float | Importe bruto asignado a esta ruta |
CreatorEarningPreview
Section titled “CreatorEarningPreview”Preview diario del mes en curso. Un registro por creador (upsert). Se sobreescribe cada día con los datos más recientes.
CreatorPaymentStatus (enum)
Section titled “CreatorPaymentStatus (enum)”PENDING_REVIEW → IN_REVIEW → APPROVED → PAID ↘ ON_HOLD ↘ REJECTED| Estado | Significado |
|---|---|
PENDING_REVIEW | Creado por el cierre; pendiente de revisión admin |
IN_REVIEW | El equipo de Contabilidad lo está procesando |
APPROVED | Aprobado para pago |
PAID | Pago enviado por Contabilidad |
ON_HOLD | En espera (documentación pendiente, disputa, etc.) |
REJECTED | No se procesará el pago (con notas justificativas) |
Panel del creador (/creator/earnings)
Section titled “Panel del creador (/creator/earnings)”Ruta protegida con CreatorGuard (CONTENT_ADMIN, SUPER_ADMIN).
Flujo de primer acceso:
- Si el creador no ha aceptado los términos del programa, la API responde
403 { code: 'EARNINGS_TERMS_NOT_ACCEPTED' }. - Se muestra un modal con el disclaimer legal y el botón de aceptación.
- Al aceptar (
PATCH /api/creator/me/accept-earnings-terms), se registraearningsTermsAcceptedAtenCreatorProfile.
Contenido del panel:
- Banner de elegibilidad — muestra si el creador es elegible este mes y el motivo si no lo es. Incluye el pool proyectado y su porcentaje de participación estimado.
- KPIs del mes en curso — completados únicos, minutos consumidos, valoración media ponderada y estimado bruto en EUR.
- Historial de períodos — tabla paginada de
CreatorMonthlyEarningcon período, completados, participación, monto bruto y estado de pago. Click en una fila abre el detalle por rutas. - Datos para Contabilidad — campo colapsable donde el creador puede añadir su referencia fiscal (NIF/NIE/CIF). El backend rechaza con
422si el texto contiene un IBAN completo o un número de tarjeta de 16 dígitos. - Disclaimer legal permanente — los importes son brutos antes de impuestos y no constituyen obligación de pago.
La tarjeta resumen EarningsPreviewCard también aparece en el dashboard principal del Creator Studio.
Panel admin (/admin/creator-payouts)
Section titled “Panel admin (/admin/creator-payouts)”Ruta protegida con AdminGuard (SUPER_ADMIN).
Flujo típico mensual:
- Preparación — el admin introduce
grossRevenue,stripeFeesTotalyrefundsTotaldel mes. El sistema calculanetRevenueycreatorPoolAmountautomáticamente. - Revisión — visualiza la tabla de creadores con sus scores, participaciones y montos brutos. Puede filtrar por estado, buscar por nombre/email.
- Cierre — puede esperar al cron del día 2 o cerrar manualmente con
POST /close. El período queda bloqueado. - Gestión de pagos — cambia estados individualmente o en bulk (selección múltiple → Aprobar / Marcar como pagado / Poner en espera).
- Exportación — descarga CSV con todos los earnings del período (incluye email, monto, estado y nota fiscal). El CSV se usa para el proceso de pago fuera de plataforma.
- Desbloqueo (si procede) — con motivo obligatorio; el motivo se registra en
adminNotes.
Vista de resumen financiero del período:
- Ingresos brutos / Comisiones Stripe / Reembolsos / Ingreso neto / Pool de creadores
- Número de creadores elegibles / Total pagado / Total pendiente
Seguridad y privacidad
Section titled “Seguridad y privacidad”- Datos bancarios: el campo
paymentDataNoteno almacena datos bancarios. El backend rechaza con422 Unprocessable Entitycualquier nota que contenga un IBAN completo (regex/^[A-Z]{2}\d{2}[A-Z0-9]{4}\d{7}([A-Z0-9]?){0,16}$/) o un número de 16 dígitos seguidos (regex/\d{16}/). Los datos bancarios reales se gestionan por el canal de Contabilidad, fuera de la plataforma. - Acceso a datos del creador: solo el propio creador puede ver sus earnings (
@CurrentUser()). El admin ve todos los creadores pero sin datos bancarios. - Bloqueo de período: una vez
isLocked = true, ningún endpoint de escritura permite modificar laPlatformRevenueConfig. El desbloqueo requiere autorización explícita con motivo documentado. - Términos del programa: el creador debe aceptar explícitamente los términos antes de acceder al panel. La aceptación se registra con timestamp (
earningsTermsAcceptedAt).
Decisiones de diseño
Section titled “Decisiones de diseño”Por qué pool mensual en lugar de comisión por venta
Section titled “Por qué pool mensual en lugar de comisión por venta”Alternativa descartada: Comisión por venta (payment_id → creatorId → commissionRate%). Simple de entender, pero requiere que cada suscripción o pago individual quede atribuido a un creador, lo cual no es trivial en un modelo de suscripción donde el alumno accede a contenido de múltiples creadores.
Decisión adoptada: Pool mensual distribuido por score de rendimiento. Desacopla los ingresos del creador de la estructura de precios y suscripciones, evita disputas de atribución y permite ajustar los criterios de distribución sin tocar el modelo de pagos.
Por qué tabla materializada y no cálculo en tiempo real
Section titled “Por qué tabla materializada y no cálculo en tiempo real”Alternativa descartada: Calcular earnings on-the-fly en cada request. Fue la primera implementación (documentada en el journal 010 como GET /creator-earnings/summary calculando en tiempo real). El problema: las queries agregadas sobre Progress con múltiples creadores son lentas y no reproducibles — el número cambia con cada request.
Decisión adoptada: CreatorMonthlyEarning como tabla materializada, actualizada por cron jobs. Los datos del mes en curso son estimaciones (preview), los del mes cerrado son definitivos e inmutables. Esta separación hace el histórico auditable y los datos de pago deterministas.
Por qué pagos manuales y no Stripe Connect
Section titled “Por qué pagos manuales y no Stripe Connect”Stripe Connect permitiría transferencias automáticas a cuentas bancarias de creadores. Se descartó porque:
- Requiere onboarding de cada creador como
Accounten Stripe (KYC, verificación de identidad, jurisdicción fiscal). - La plataforma opera con creadores en distintas jurisdicciones fiscales (España, LATAM) con tratamientos diferentes.
- El volumen inicial de creadores no justifica la complejidad operativa de Stripe Connect.
Decisión: los pagos se procesan manualmente por el equipo de Contabilidad usando el CSV exportado. La plataforma gestiona el ciclo de aprobación y la trazabilidad, pero no mueve fondos directamente.