Skip to content

Pagos a Creadores

✅ Implementado

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 completo
  • apps/web/src/pages/creator/CreatorEarningsPage.tsx — dashboard del creador
  • apps/web/src/pages/admin/AdminCreatorPayoutsPage.tsx — panel admin de payouts
  • apps/web/src/components/creator/EarningsPreviewCard.tsx — tarjeta resumen en Creator Studio
  • apps/web/src/store/api/creatorEarningsApi.ts — slice RTK Query
  • packages/shared-types/src/creator-earnings.ts — tipos TypeScript compartidos
  • packages/shared-schemas/src/creator-earnings.schemas.ts — schemas Zod de validación

El sistema no asigna un porcentaje fijo por venta. En su lugar:

  1. Al cierre de cada mes, el SUPER_ADMIN introduce los datos financieros del período (ingresos brutos, comisiones Stripe, reembolsos) en el panel admin.
  2. El sistema calcula el ingreso neto y sobre él aplica el poolPercentage (por defecto 25%) para obtener el creatorPoolAmount.
  3. Ese importe se reparte entre todos los creadores elegibles en proporción a su score de rendimiento relativo.
grossRevenue - stripeFeesTotal - refundsTotal = netRevenue
netRevenue × poolPercentage = creatorPoolAmount

Cada mes tiene su propio PlatformRevenueConfig con parámetros ajustables antes del cierre:

CampoValor por defectoDescripción
poolPercentage0.25 (25%)Fracción del ingreso neto que va al pool de creadores
minCompletionsForEligibility5Mínimo de completados en el mes para ser elegible
minReviewsForRating3Mínimo de reseñas en una ruta para que su rating cuente en el score

Un creador es elegible para un período si cumple las tres condiciones:

  1. Perfil no suspendido (suspendedAt IS NULL)
  2. Al menos 1 ruta publicada (status = PUBLISHED)
  3. Al menos minCompletionsForEligibility completados en el período (por defecto 5)

Si no cumple alguna condición, se almacena el motivo de inelegibilidad:

ineligibilityReasonCausa
PROFILE_SUSPENDEDEl perfil del creador está suspendido
NO_PUBLISHED_PATHSNo tiene rutas en estado PUBLISHED
INSUFFICIENT_COMPLETIONSCompletados del período < umbral mínimo

Cada creador elegible obtiene un score compuesto de tres factores:

score = completions × 0.5 + minutes × 0.3 + weightedRating × 0.2

Donde:

  • 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 con reviewCount >= minReviewsForRating
participationPct = score / totalScore (suma de scores de todos los elegibles)
grossAmountEur = creatorPoolAmount × participationPct

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.2
contributionPct = pathScore / creatorScore
pathGrossEur = grossAmountEur × contributionPct

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 --> M

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

Cron: 0 6 2 * * (06:00 UTC el día 2 de cada mes)

Procesa el mes anterior de forma definitiva:

  1. Calcula métricas reales del período cerrado
  2. Crea/actualiza CreatorMonthlyEarning con paymentStatus = PENDING_REVIEW
  3. Crea CreatorEarningLine por ruta (detalle del desglose)
  4. Bloquea (isLocked = true) la PlatformRevenueConfig del 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.


GET /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).
GET /api/creator/me/earnings JWT (CONTENT_ADMIN) Historial paginado de earnings cerrados. Parámetros: page, limit (default 12).
GET /api/creator/me/earnings/:period JWT (CONTENT_ADMIN) Detalle de un período específico (YYYY-MM) con líneas por ruta.
PATCH /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.
PATCH /api/creator/me/accept-earnings-terms JWT (CONTENT_ADMIN) Acepta los términos del programa de ingresos. Necesario para ver datos del panel.
GET /api/admin/creator-payouts/periods JWT + SUPER_ADMIN Lista todos los períodos con su configuración y estado de bloqueo.
GET /api/admin/creator-payouts/periods/:periodMonth JWT + SUPER_ADMIN Detalle completo de un período: config, earnings de todos los creadores y resumen financiero.
PATCH /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.
POST /api/admin/creator-payouts/periods/:periodMonth/close JWT + SUPER_ADMIN Cierre manual de un período (alternativa al cron job automático).
POST /api/admin/creator-payouts/periods/:periodMonth/unlock JWT + SUPER_ADMIN Desbloquea un período cerrado. Requiere motivo obligatorio; el motivo queda registrado en adminNotes.
PATCH /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).
GET /api/admin/creator-payouts/periods/:periodMonth/export JWT + SUPER_ADMIN Exporta el período como CSV. Marca exportedAt en cada earning.
GET /api/admin/creator-payouts/audit-log JWT + SUPER_ADMIN Historial de cambios de estado de pagos para un período (query param: periodMonth).
POST /api/admin/creator-payouts/jobs/run-preview JWT + SUPER_ADMIN Lanza manualmente el job de preview del mes en curso.
GET /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.

Configuración financiera de cada período mensual. Un registro por mes.

CampoTipoDescripción
periodMonthString (UNIQUE)Formato YYYY-MM
poolPercentageFloatFracción del neto para creadores (0.25 = 25%)
minCompletionsForEligibilityIntMínimo de completados para ser elegible (default 5)
minReviewsForRatingIntMínimo de reseñas para que el rating cuente (default 3)
grossRevenueFloat?Ingresos brutos del período (entrada manual del admin)
stripeFeesTotalFloat?Comisiones Stripe del período (entrada manual)
refundsTotalFloat?Reembolsos del período (entrada manual)
netRevenueFloat?Calculado: gross - fees - refunds
creatorPoolAmountFloat?Calculado: netRevenue × poolPercentage
isLockedBooleantrue tras el cierre definitivo del período
lockedAtDateTime?Timestamp del cierre
lockedByString?userId del admin o 'system' (cron)
adminNotesText?Notas del admin; los desbloqueos quedan registrados aquí

Un registro por creador por período. Se crea en el cierre del mes.

CampoTipoDescripción
creatorIdStringFK a CreatorProfile
configIdStringFK a PlatformRevenueConfig
periodMonthStringFormato YYYY-MM
completionsCountIntTotal de lecciones completadas en el período
minutesConsumedIntMinutos (aprox.: completions × 15)
weightedRatingFloatRating ponderado entre rutas con suficientes reseñas
reviewsCountIntTotal de reseñas consideradas
rawScoreFloatScore calculado: c×0.5 + m×0.3 + r×0.2
participationPctFloatrawScore / totalScoreEligibles
grossAmountEurFloatcreatorPoolAmount × participationPct
isEligibleBooleanSi cumplió todos los criterios de elegibilidad
ineligibilityReasonString?Causa de inelegibilidad
paymentStatusCreatorPaymentStatusEstado del pago (ver tabla abajo)
paymentNotesText?Notas del admin sobre el pago
paymentStatusUpdatedByString?userId del admin que cambió el estado
exportedAtDateTime?Timestamp cuando se exportó a CSV

Índice único: (creatorId, periodMonth)

Detalle del earning de un creador desglosado por ruta. Cascade delete con CreatorMonthlyEarning.

CampoTipoDescripción
earningIdStringFK a CreatorMonthlyEarning
pathIdStringFK a Path
pathTitle / pathSlugStringDesnormalizados para historial estable
completionsCountIntCompletados del período en esta ruta
minutesConsumedIntMinutos en esta ruta
avgRatingFloat?Rating medio de la ruta
contributionPctFloatFracción del earning total del creador
grossAmountEurFloatImporte bruto asignado a esta ruta

Preview diario del mes en curso. Un registro por creador (upsert). Se sobreescribe cada día con los datos más recientes.

PENDING_REVIEW → IN_REVIEW → APPROVED → PAID
↘ ON_HOLD
↘ REJECTED
EstadoSignificado
PENDING_REVIEWCreado por el cierre; pendiente de revisión admin
IN_REVIEWEl equipo de Contabilidad lo está procesando
APPROVEDAprobado para pago
PAIDPago enviado por Contabilidad
ON_HOLDEn espera (documentación pendiente, disputa, etc.)
REJECTEDNo se procesará el pago (con notas justificativas)

Ruta protegida con CreatorGuard (CONTENT_ADMIN, SUPER_ADMIN).

Flujo de primer acceso:

  1. Si el creador no ha aceptado los términos del programa, la API responde 403 { code: 'EARNINGS_TERMS_NOT_ACCEPTED' }.
  2. Se muestra un modal con el disclaimer legal y el botón de aceptación.
  3. Al aceptar (PATCH /api/creator/me/accept-earnings-terms), se registra earningsTermsAcceptedAt en CreatorProfile.

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 CreatorMonthlyEarning con 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 422 si 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.


Ruta protegida con AdminGuard (SUPER_ADMIN).

Flujo típico mensual:

  1. Preparación — el admin introduce grossRevenue, stripeFeesTotal y refundsTotal del mes. El sistema calcula netRevenue y creatorPoolAmount automáticamente.
  2. Revisión — visualiza la tabla de creadores con sus scores, participaciones y montos brutos. Puede filtrar por estado, buscar por nombre/email.
  3. Cierre — puede esperar al cron del día 2 o cerrar manualmente con POST /close. El período queda bloqueado.
  4. Gestión de pagos — cambia estados individualmente o en bulk (selección múltiple → Aprobar / Marcar como pagado / Poner en espera).
  5. 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.
  6. 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

  • Datos bancarios: el campo paymentDataNote no almacena datos bancarios. El backend rechaza con 422 Unprocessable Entity cualquier 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 la PlatformRevenueConfig. 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).

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 Account en 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.