Saltar al contenido principal

Retiros de creador (Cashout) — Referencia técnica

← Volver a Retiros de creador (Cashout)

Dónde vive esto

Backend

Frontend

apps/frontend-nextjs/src/page-components/PaymentsPage.tsx y CoinsPage.tsx todavía existen en el árbol, pero ya no son importados por ninguna ruta — la ruta de la app /payments ahora solo redirige a /settings/payment-methods, y la compra de monedas vive en /settings/get-coins (GetCoinsPage.tsx, sin interfaz de retiro). Trata esos dos archivos como código muerto, no como los hosts actuales.

Checklist de implementación técnica

  • myCashoutEligibility / myEarningsSummary — conectado; EarningsSummaryCard.tsx / EarningsBySourceChart.tsx
  • startPayoutOnboarding / refreshPayoutAccountStatus / myPayoutAccount — conectado; ConnectBankAccountCard.tsx
  • requestCashout (Stripe) — conectado; CashoutRequestForm.tsx
  • submitPayoutProfile / myPayoutProfile — conectado y ahora sensible al país; PayoutProfileForm.tsx
  • requestManualCashout — conectado; ManualCashoutRequestForm.tsx
  • myCashouts — conectado; CashoutHistoryList.tsx
  • Revisión de administrador (payout-admin.resolver.js) y el interruptor de plataforma cashoutProvider — solo frontend-admin; no auditado en esta pasada
  • Flujos de retiro manual para países más allá de México — PayoutProfile ganó un discriminador payoutCountry más campos bancarios genéricos (accountHolderName, bankName, accountNumber, swiftBic, taxId); clabe/rfc ahora son nullable y solo para MX. La validación y submitPayoutProfile se ramifican según el país (ver abajo).
  • Estado de ganancias anual autogestionado (myEarningsStatement) — EarningsStatementCard.tsx en /settings/insights-tools; resumen descargable por año.
  • Tendencia diaria de ganancias (myEarningsTimeSeries) — EarningsTrendChart.tsx en /settings/insights-tools; selector de rango 7d/30d/90d.
  • Bloqueo por verificación de identidad en los retiros — identityVerificationManager.requireApprovedForCashout es llamado tanto por requestCashout como por requestManualCashout; un usuario cuyo identityVerificationStatus no sea approved recibe un error, y PayoutsPage.tsx muestra un banner de bloqueo (a través de myIdentityVerificationStatus) en lugar de los formularios de solicitud. Consulta la documentación de verificación de edad e identidad para el flujo de envío/aprobación en sí.
  • Generación de documentos fiscales oficiales específicos por jurisdicción (constancia de retención, 1099, etc.) — aún necesita un proveedor fiscal externo; solo existe el resumen autogestionado de arriba.

Elegibilidad de retiro (CashoutEligibility)

myCashoutEligibility (y su alias myEarningsSummary, con la misma forma) es la única fuente de verdad para la pantalla de retiro — devuelve el saldo, los totales de por vida, y cada ajuste necesario para renderizar correctamente el formulario de solicitud.

query MyCashoutEligibility {
myCashoutEligibility {
balance
lifetimeEarned
lifetimeCashedOut
pendingCashoutCoins
unmaturedRecentEarnings
payoutHoldDays
minCashoutCoins
coinsPerUsd
payoutsEnabledGlobally
cashoutProvider
availableForCashout
}
}

unmaturedRecentEarnings son monedas ganadas demasiado recientemente para retirarlas todavía (ver payoutHoldDays). cashoutProvider ("stripe" o "manual") le indica al frontend qué flujo está activo actualmente a nivel de plataforma — un valor configurado por un administrador — por lo que la interfaz debe mostrar el formulario de solicitud correspondiente en lugar de mostrar ambos.

lifetimeEarned (UserCoinBalance.lifetimeEarned) está pensado estrictamente para dinero ganado de otros usuarios — propinas, ventas de posts/productos exclusivos, ingresos por suscripción — y acota directamente availableForCashout (maxByLifetimeEarnings = lifetimeEarned - lifetimeCashedOut en coin-cashout.manager.js#getEligibility). completePurchase/refundPurchase de coin-purchase.manager.js (por donde pasa toda compra de paquete de monedas — Stripe, PayPal, PayPal guardado, IAP nativo) antes también acreditaba lifetimeEarned por el monto comprado, además de lifetimePurchased. Eso era un bug, no una funcionalidad: permitía que las compras propias de monedas de un usuario inflaran la cifra de "Total ganado" mostrada en EarningsSummaryCard.tsx, y en principio permitía solicitar como retiro monedas compradas pero no gastadas. Corregido — completePurchase/refundPurchase ahora solo tocan lifetimePurchased.

Flujo de Stripe Connect

mutation StartPayoutOnboarding($input: StartPayoutOnboardingInput!) {
startPayoutOnboarding(input: $input) { url expiresAt }
}

mutation RefreshPayoutAccountStatus {
refreshPayoutAccountStatus { status payoutsEnabled detailsSubmitted bankLast4 bankName }
}

mutation RequestCashout($coinAmount: Int!) {
requestCashout(coinAmount: $coinAmount) { id status cashAmount currency provider }
}

startPayoutOnboarding devuelve una URL de onboarding alojada por Stripe (refreshUrl/returnUrl son adonde Stripe redirige si se abandona/completa). Una vez que termina el onboarding, refreshPayoutAccountStatus vuelve a sincronizar payoutsEnabled/detailsSubmitted desde Stripe.

Tanto requestCashout como requestManualCashout primero llaman a identityVerificationManager.requireApprovedForCashout(userId) — un usuario cuya verificación de identidad no esté approved recibe un error en lugar de un retiro, sin importar el proveedor.

Flujo manual (sensible al país: MX = CLABE/RFC, otros = banco genérico)

El perfil de pago se ramifica según payoutCountry (ISO-3166 alfa-2, por defecto MX):

  • MX requiere clabe (CLABE interbancaria de 18 dígitos, validada por checksum) + rfc.
  • Cualquier otro país requiere accountHolderName + bankName + accountNumber (o IBAN); swiftBic y taxId son opcionales. Enviar una vía limpia los campos de la otra, así que un perfil nunca lleva datos de MX + internacionales obsoletos a la vez.

submitPayoutProfile valida el conjunto correcto por país (payout-profile.validator.js#validateInternationalBank / validateClabe + validateRfc); la dirección de facturación se requiere en ambos casos.

Tendencia diaria de ganancias (myEarningsTimeSeries)

myEarningsTimeSeries(days: Int): [EarningsTimePoint!]! (por defecto 30 días) devuelve una serie { date, earnings } rellenada con ceros para el gráfico de tendencia. EarningsTrendChart.tsx la renderiza con un selector de rango 7d/30d/90d en /settings/insights-tools.

Estado de ganancias anual (myEarningsStatement)

myEarningsStatement(year: Int): EarningsStatement devuelve un resumen del año calendario — ganancias por fuente más totalCashedOut (retiros liquidados paid/completed de ese año) — construido en coin-transaction.manager.js#getEarningsStatement. Es un resumen autogestionado que un creador entrega a su contador, no un formato fiscal oficial. El frontend (EarningsStatementCard.tsx) lo renderiza y ofrece una descarga en texto plano.

Flujo manual (CLABE/RFC)

mutation SubmitPayoutProfile($input: PayoutProfileInput!) {
submitPayoutProfile(input: $input) { id clabe rfc billingCity billingCountry }
}

mutation RequestManualCashout($coinAmount: Int!) {
requestManualCashout(coinAmount: $coinAmount) { id status provider cashAmount }
}

Las solicitudes del flujo manual dejan las monedas solicitadas en garantía (escrow) — igual que el flujo de Stripe — hasta que un administrador mueve la solicitud de pendingprocessingcompleted, o la rechaza (lo que devuelve las monedas al usuario automáticamente). payoutProfile en CoinCashout le permite a un administrador ver la CLABE/RFC/dirección de facturación registrada del solicitante sin una segunda consulta.

Historial

query MyCashouts($limit: Int, $offset: Int) {
myCashouts(limit: $limit, offset: $offset) {
id
coinAmount
cashAmount
currency
status
provider
failureReason
canceledReason
processedAt
createdAt
}
}

CoinCashout.status difiere ligeramente según el proveedor: los retiros de Stripe pasan de pendingpaid (transferencia síncrona) o failed (con failureReason establecido, monedas revertidas) — un administrador también puede cancelar uno pendiente/en proceso (estado canceled, con canceledReason establecido). Los retiros manuales pasan de pendingprocessingcompleted, o rejected (con canceledReason establecido, monedas revertidas).