Retiros de creador (Cashout) — Referencia técnica
← Volver a Retiros de creador (Cashout)
Dónde vive esto
Backend
apps/backend/graphql/types/coin-payout.type.js— schema de GraphQL dePayoutAccount,CashoutEligibility,CoinCashout,PayoutProfileapps/backend/graphql/resolvers/coin-payout.resolver.js— resuelvemyPayoutAccount,myCashoutEligibility,myEarningsSummary,myCashouts,myPayoutProfile,startPayoutOnboarding,refreshPayoutAccountStatus,requestCashout,submitPayoutProfile,requestManualCashoutapps/backend/managers/coin-managers/coin-cashout.manager.js— cálculo de elegibilidad, manejo de garantía (escrow) y la lógica de negocio de Stripe Connect + el flujo manual (CLABE/RFC)apps/backend/graphql/types/coin-transaction.type.jsymanagers/coin-managers/coin-transaction.manager.js— consultas de ganancias usadas por el panel de retiros/analítica:myEarningsBySource,myEarningsTimeSeries,myEarningsStatementapps/backend/managers/user-managers/identity-verification.manager.js— guardiarequireApprovedForCashout(userId); tantorequestCashoutcomorequestManualCashoutla llaman antes de mover cualquier moneda, por lo que un retiro ahora requiere una verificación de identidad aprobada por un administrador (ver la documentación de verificación de edad e identidad)apps/backend/graphql/types/admin/payout-admin.type.jsyresolvers/admin/payout-admin.resolver.js— cola de revisión de administrador (marcar en proceso/completado, rechazar con devolución automática de monedas) y el ajuste de plataformacashoutProvider(stripe/manual) enSystemSetting— esquema de administración separado, no auditado como parte de esta página
Frontend
apps/frontend-nextjs/src/page-components/settings/PayoutsPage.tsx— enrutada en/settings/payouts; aloja las tarjetas de abajo, ramificadas segúncashoutProvider, más un banner de bloqueo por verificación de identidad (consultamyIdentityVerificationStatus, enlaza a/settings/identity-verificationhasta que se apruebe)apps/frontend-nextjs/src/components/payouts/ConnectBankAccountCard.tsx— tarjeta de onboarding/estado de Stripe Connectapps/frontend-nextjs/src/components/payouts/CashoutRequestForm.tsx— formulario de solicitud de retiro vía Stripeapps/frontend-nextjs/src/components/payouts/ManualCashoutRequestForm.tsx— formulario de solicitud de retiro manual (CLABE/RFC)apps/frontend-nextjs/src/components/payouts/PayoutProfileForm.tsx— registra/edita el perfil de retiro CLABE/RFC/dirección de facturación (usado dentro deManualCashoutRequestForm.tsx)apps/frontend-nextjs/src/components/payouts/CashoutHistoryList.tsx— lista del historial de retirosapps/frontend-nextjs/src/page-components/settings/InsightsAndToolsPage.tsx— enrutada en/settings/insights-tools; aloja las tarjetas de ganancias de abajoapps/frontend-nextjs/src/components/payouts/EarningsSummaryCard.tsx— resumen de saldo/ganancias de por vidaapps/frontend-nextjs/src/components/payouts/EarningsTrendChart.tsx— gráfico de tendencia diaria de ganancias (7d/30d/90d), respaldado pormyEarningsTimeSeriesapps/frontend-nextjs/src/components/payouts/EarningsBySourceChart.tsx— gráfico de desglose de gananciasapps/frontend-nextjs/src/components/payouts/EarningsStatementCard.tsx— estado de ganancias anual, respaldado pormyEarningsStatement
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 plataformacashoutProvider— solofrontend-admin; no auditado en esta pasada - Flujos de retiro manual para países más allá de México —
PayoutProfileganó un discriminadorpayoutCountrymás campos bancarios genéricos (accountHolderName,bankName,accountNumber,swiftBic,taxId);clabe/rfcahora son nullable y solo para MX. La validación ysubmitPayoutProfilese ramifican según el país (ver abajo). - Estado de ganancias anual autogestionado (
myEarningsStatement) —EarningsStatementCard.tsxen/settings/insights-tools; resumen descargable por año. - Tendencia diaria de ganancias (
myEarningsTimeSeries) —EarningsTrendChart.tsxen/settings/insights-tools; selector de rango 7d/30d/90d. - Bloqueo por verificación de identidad en los retiros —
identityVerificationManager.requireApprovedForCashoutes llamado tanto porrequestCashoutcomo porrequestManualCashout; un usuario cuyoidentityVerificationStatusno seaapprovedrecibe un error, yPayoutsPage.tsxmuestra un banner de bloqueo (a través demyIdentityVerificationStatus) 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):
MXrequiereclabe(CLABE interbancaria de 18 dígitos, validada por checksum) +rfc.- Cualquier otro país requiere
accountHolderName+bankName+accountNumber(o IBAN);swiftBicytaxIdson 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 pending → processing → completed, 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 pending → paid (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 pending → processing → completed, o rejected (con canceledReason establecido, monedas revertidas).