Creator Payouts (Cashout) — Technical Reference
← Back to Creator Payouts (Cashout)
Where this lives
Backend
apps/backend/graphql/types/coin-payout.type.js—PayoutAccount,CashoutEligibility,CoinCashout,PayoutProfileGraphQL schemaapps/backend/graphql/resolvers/coin-payout.resolver.js— resolvesmyPayoutAccount,myCashoutEligibility,myEarningsSummary,myCashouts,myPayoutProfile,startPayoutOnboarding,refreshPayoutAccountStatus,requestCashout,submitPayoutProfile,requestManualCashoutapps/backend/managers/coin-managers/coin-cashout.manager.js— eligibility calculation, escrow handling, and the Stripe Connect + manual (CLABE/RFC) business logicapps/backend/graphql/types/coin-transaction.type.jsandmanagers/coin-managers/coin-transaction.manager.js— earnings queries used by the payouts/insights dashboard:myEarningsBySource,myEarningsTimeSeries,myEarningsStatementapps/backend/managers/user-managers/identity-verification.manager.js—requireApprovedForCashout(userId)guard; bothrequestCashoutandrequestManualCashoutcall it before moving any coins, so a cashout now requires an admin-approved identity verification (see the age & identity verification docs)apps/backend/graphql/types/admin/payout-admin.type.jsandresolvers/admin/payout-admin.resolver.js— admin review queue (mark processing/completed, reject with automatic coin return) and the platform-widecashoutProvider(stripe/manual) setting onSystemSetting— separate admin schema, not audited as part of this page
Frontend
apps/frontend-nextjs/src/page-components/settings/PayoutsPage.tsx— routed at/settings/payouts; hosts the cards below, branched oncashoutProvider, plus an identity-verification gate banner (queriesmyIdentityVerificationStatus, links to/settings/identity-verificationuntil approved)apps/frontend-nextjs/src/components/payouts/ConnectBankAccountCard.tsx— Stripe Connect onboarding/status cardapps/frontend-nextjs/src/components/payouts/CashoutRequestForm.tsx— Stripe cashout request formapps/frontend-nextjs/src/components/payouts/ManualCashoutRequestForm.tsx— manual (CLABE/RFC) cashout request formapps/frontend-nextjs/src/components/payouts/PayoutProfileForm.tsx— registers/edits the CLABE/RFC/billing-address payout profile (used insideManualCashoutRequestForm.tsx)apps/frontend-nextjs/src/components/payouts/CashoutHistoryList.tsx— cashout history listapps/frontend-nextjs/src/page-components/settings/InsightsAndToolsPage.tsx— routed at/settings/insights-tools; hosts the earnings cards belowapps/frontend-nextjs/src/components/payouts/EarningsSummaryCard.tsx— balance/lifetime-earnings summaryapps/frontend-nextjs/src/components/payouts/EarningsTrendChart.tsx— daily earnings trend chart (7d/30d/90d), backed bymyEarningsTimeSeriesapps/frontend-nextjs/src/components/payouts/EarningsBySourceChart.tsx— earnings breakdown chartapps/frontend-nextjs/src/components/payouts/EarningsStatementCard.tsx— annual earnings statement, backed bymyEarningsStatement
apps/frontend-nextjs/src/page-components/PaymentsPage.tsx and CoinsPage.tsx still exist in the tree but are no longer imported by any route — the /payments app route now just redirects to /settings/payment-methods, and coin purchasing lives at /settings/get-coins (GetCoinsPage.tsx, no payout UI). Treat those two files as dead code, not the current hosts.
Technical implementation checklist
-
myCashoutEligibility/myEarningsSummary— wired;EarningsSummaryCard.tsx/EarningsBySourceChart.tsx -
startPayoutOnboarding/refreshPayoutAccountStatus/myPayoutAccount— wired;ConnectBankAccountCard.tsx -
requestCashout(Stripe) — wired;CashoutRequestForm.tsx -
submitPayoutProfile/myPayoutProfile— wired and now country-aware;PayoutProfileForm.tsx -
requestManualCashout— wired;ManualCashoutRequestForm.tsx -
myCashouts— wired;CashoutHistoryList.tsx - Admin review (
payout-admin.resolver.js) and thecashoutProviderplatform toggle —frontend-adminonly; not audited as part of this pass - Manual payout flows for countries beyond Mexico —
PayoutProfilegained apayoutCountrydiscriminator plus generic bank fields (accountHolderName,bankName,accountNumber,swiftBic,taxId);clabe/rfcare now nullable and MX-only. Validation andsubmitPayoutProfilebranch on the country (see below). - Self-serve annual earnings statement (
myEarningsStatement) —EarningsStatementCard.tsxon/settings/insights-tools; downloadable per-year summary. - Daily earnings trend (
myEarningsTimeSeries) —EarningsTrendChart.tsxon/settings/insights-tools; 7d/30d/90d range toggle. - Identity-verification gate on cashouts —
identityVerificationManager.requireApprovedForCashoutis called by bothrequestCashoutandrequestManualCashout; a user whoseidentityVerificationStatusisn'tapprovedgets an error, andPayoutsPage.tsxshows a blocking banner (viamyIdentityVerificationStatus) instead of the request forms. See the age & identity verification docs for the submission/approval flow itself. - Official jurisdiction-specific tax-document generation (constancia de retención, 1099, etc.) — still needs an external tax provider; only the self-serve summary above exists.
Cashout eligibility (CashoutEligibility)
myCashoutEligibility (and its alias myEarningsSummary, same shape) is the single source of truth for the cashout screen — it returns the balance, the lifetime totals, and every setting needed to render the request form correctly.
query MyCashoutEligibility {
myCashoutEligibility {
balance
lifetimeEarned
lifetimeCashedOut
pendingCashoutCoins
unmaturedRecentEarnings
payoutHoldDays
minCashoutCoins
coinsPerUsd
payoutsEnabledGlobally
cashoutProvider
availableForCashout
}
}
unmaturedRecentEarnings is coins earned too recently to cash out yet (see payoutHoldDays). cashoutProvider ("stripe" or "manual") tells the frontend which flow is currently active platform-wide — an admin-configured value — so the UI should render the matching request form instead of showing both.
lifetimeEarned (UserCoinBalance.lifetimeEarned) is meant strictly for money earned from other users — tips, exclusive-post/product sales, subscription revenue — and directly bounds availableForCashout (maxByLifetimeEarnings = lifetimeEarned - lifetimeCashedOut in coin-cashout.manager.js#getEligibility). coin-purchase.manager.js's completePurchase/refundPurchase (every coin-package purchase path — Stripe, PayPal, saved PayPal, native IAP — funnels through these two) previously also credited lifetimeEarned by the purchased amount, alongside lifetimePurchased. That was a bug, not a feature: it let a user's own coin purchases inflate the "Total earned" figure shown on EarningsSummaryCard.tsx, and in principle let purchased-but-unspent coins be requested as a cashout. Fixed — completePurchase/refundPurchase now only touch lifetimePurchased.
Stripe Connect flow
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 returns a Stripe-hosted onboarding URL (refreshUrl/returnUrl are where Stripe redirects on abandon/completion). Once onboarding completes, refreshPayoutAccountStatus re-syncs payoutsEnabled/detailsSubmitted from Stripe.
Both requestCashout and requestManualCashout first call identityVerificationManager.requireApprovedForCashout(userId) — a user whose identity verification isn't approved gets an error instead of a cashout, regardless of provider.
Manual flow (country-aware: MX = CLABE/RFC, others = generic bank)
The payout profile branches on payoutCountry (ISO-3166 alpha-2, default MX):
MXrequiresclabe(18-digit CLABE interbancaria, checksum-validated) +rfc.- Any other country requires
accountHolderName+bankName+accountNumber(or IBAN);swiftBicandtaxIdare optional. Submitting one path clears the other's fields, so a profile never carries stale MX + international data at once.
submitPayoutProfile validates the right set per country (payout-profile.validator.js#validateInternationalBank / validateClabe + validateRfc); the billing address is required either way.
Daily earnings trend (myEarningsTimeSeries)
myEarningsTimeSeries(days: Int): [EarningsTimePoint!]! (defaults to 30 days) returns a zero-filled { date, earnings } series for the trend chart. EarningsTrendChart.tsx renders it with a 7d/30d/90d range toggle on /settings/insights-tools.
Annual earnings statement (myEarningsStatement)
myEarningsStatement(year: Int): EarningsStatement returns a calendar-year summary — earnings by source plus totalCashedOut (settled paid/completed cashouts that year) — built in coin-transaction.manager.js#getEarningsStatement. It's a self-serve summary a creator hands to their accountant, not an official tax form. The frontend (EarningsStatementCard.tsx) renders it and offers a plain-text download.
Manual (CLABE/RFC) flow
mutation SubmitPayoutProfile($input: PayoutProfileInput!) {
submitPayoutProfile(input: $input) { id clabe rfc billingCity billingCountry }
}
mutation RequestManualCashout($coinAmount: Int!) {
requestManualCashout(coinAmount: $coinAmount) { id status provider cashAmount }
}
Manual-flow requests hold the requested coins in escrow — the same as the Stripe flow — until an admin moves the request through pending → processing → completed, or rejects it (which returns the coins to the user automatically). payoutProfile on CoinCashout lets an admin see the requester's registered CLABE/RFC/billing address without a second query.
History
query MyCashouts($limit: Int, $offset: Int) {
myCashouts(limit: $limit, offset: $offset) {
id
coinAmount
cashAmount
currency
status
provider
failureReason
canceledReason
processedAt
createdAt
}
}
CoinCashout.status differs slightly by provider: Stripe cashouts go pending → paid (synchronous transfer) or failed (failureReason set, coins reversed) — an admin can also cancel a pending/processing one (status canceled, canceledReason set). Manual cashouts go pending → processing → completed, or rejected (canceledReason set, coins reversed).