Skip to main content

Creator Payouts (Cashout) — Technical Reference

← Back to Creator Payouts (Cashout)

Where this lives

Backend

Frontend

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 the cashoutProvider platform toggle — frontend-admin only; not audited as part of this pass
  • Manual payout flows for countries beyond Mexico — PayoutProfile gained a payoutCountry discriminator plus generic bank fields (accountHolderName, bankName, accountNumber, swiftBic, taxId); clabe/rfc are now nullable and MX-only. Validation and submitPayoutProfile branch on the country (see below).
  • Self-serve annual earnings statement (myEarningsStatement) — EarningsStatementCard.tsx on /settings/insights-tools; downloadable per-year summary.
  • Daily earnings trend (myEarningsTimeSeries) — EarningsTrendChart.tsx on /settings/insights-tools; 7d/30d/90d range toggle.
  • Identity-verification gate on cashouts — identityVerificationManager.requireApprovedForCashout is called by both requestCashout and requestManualCashout; a user whose identityVerificationStatus isn't approved gets an error, and PayoutsPage.tsx shows a blocking banner (via myIdentityVerificationStatus) 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):

  • MX requires clabe (18-digit CLABE interbancaria, checksum-validated) + rfc.
  • Any other country requires accountHolderName + bankName + accountNumber (or IBAN); swiftBic and taxId are 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 pendingprocessingcompleted, 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 pendingpaid (synchronous transfer) or failed (failureReason set, coins reversed) — an admin can also cancel a pending/processing one (status canceled, canceledReason set). Manual cashouts go pendingprocessingcompleted, or rejected (canceledReason set, coins reversed).