Monedas y propinas — Referencia técnica
Dónde vive esto
Backend
apps/backend/graphql/resolvers/coin-package.resolver.js— solo el listado público de paquetes de monedas (coinPackages/coinPackage/popularCoinPackages/bestValueCoinPackage/allCoinPackages); las mutaciones CRUD que antes vivían aquí fueron eliminadas — ver el resolver de administrador más abajoapps/backend/graphql/resolvers/admin/coin-package-admin.resolver.js— CRUD de paquetes de monedas dentro del namespace de administrador (adminGetCoinPackages/adminCreateCoinPackage/adminUpdateCoinPackage/adminActivateCoinPackage/adminDeactivateCoinPackage/adminDeleteCoinPackage), protegido porcontext.admin+ super_admin; delega en el mismocoin-package.manager.jsapps/backend/graphql/resolvers/coin-purchase.resolver.js—purchaseCoinsWithPayment(tarjeta guardada), el flujo de Stripe PaymentIntent (createCoinPurchaseIntent/confirmCoinPurchaseIntent), el flujo de PayPal (createPaypalCoinOrder/capturePaypalCoinOrder) y los resolvers de historial/estadísticas de comprasapps/backend/graphql/resolvers/coin-transaction.resolver.js— resolvers de saldo de billetera y libro mayor de transaccionesapps/backend/graphql/resolvers/coin-tip.resolver.js— resolvers de sendTip e historial/estadísticas de propinasapps/backend/managers/coin-managers/coin-purchase.manager.js— flujo de compra — cobra el método de pago (tarjeta guardada de Stripe, Stripe PaymentIntent para wallets, o PayPal) y acredita las monedasapps/backend/managers/coin-managers/coin-tip.manager.js— lógica de negocio de envío/agregación de propinasapps/backend/managers/coin-managers/coin-transaction.manager.js— lógica de negocio de saldo y libro mayorapps/backend/managers/coin-managers/platform-wallet.manager.js— billetera de monedas única a nivel de plataforma que opera el administrador (recarga, transferencia a un usuario, retiro de un usuario); los movimientos del lado del usuario pasan porcoin-transaction.manager.jspara que el saldo/libro mayor se mantengan consistentesapps/backend/graphql/resolvers/admin/platform-wallet.resolver.js— resolvers de administrador de la billetera de plataforma (adminPlatformWallet/adminGetUserCoinBalance/adminTopUpWallet/adminTransferCoinsToUser/adminTakeCoinsFromUser), solo super_adminapps/backend/graphql/resolvers/message.resolver.js— sendCoinsViaMessage — transferencia de monedas incrustada en un mensaje de chatapps/backend/services/stripe— integración de Stripe usada para pagos con tarjeta y PaymentIntents (Apple Pay / Google Pay) detrás de una compra de monedasapps/backend/services/paypal— integración de orden/captura de PayPal usada para las compras de monedas vía PayPal
Frontend
apps/frontend-nextjs/src/page-components/CoinsPage.tsx— muestra el saldo de la billetera y el flujo de compra de paquetes de monedas; renderizaCoinExpressCheckout(Apple Pay / Google Pay / PayPal) encima deStripeCheckout(tarjeta guardada/nueva)apps/frontend-nextjs/src/components/payment/CoinExpressCheckout.tsx— Stripe Express Checkout Element (Apple Pay / Google Pay) y botones de PayPal para comprar monedas; cada método solo se renderiza cuando está configurado (NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,NEXT_PUBLIC_PAYPAL_CLIENT_ID)apps/frontend-nextjs/src/page-components/CoinTransactionsPage.tsx— lista del historial de compras/transaccionesapps/frontend-nextjs/src/components/coins/CoinsModal.tsx— modal de compra de monedasapps/frontend-nextjs/src/app/coins/page.tsx— ruta de monedasapps/frontend-nextjs/src/app/coins/transactions/page.tsx— ruta del historial de transaccionesapps/frontend-admin/src/app/coins/page.tsx— interfaz de administración de paquetes de monedas (listar/crear/actualizar/activar/desactivar/eliminar), invoca las operacionesadmin*CoinPackagede arribaapps/frontend-admin/src/components/UserCoinsCard.tsx— en la página de detalle de usuario del admin (apps/frontend-admin/src/app/users/[id]/page.tsx), muestra el saldo de monedas del usuario y el saldo de la billetera de plataforma, y permite a un super_admin transferir monedas hacia/desde el usuario o recargar la billetera de plataforma
sendTip está conectado de punta a punta vía handleSendTip de ChatView.tsx, invocado desde un TipModal (este documento decía antes que no existía interfaz de frontend para propinas — corregido).
Checklist de implementación técnica
-
purchaseCoinsWithPayment(tarjeta guardada de Stripe) — backendcoin-purchase.resolver.js+ frontendCoinsPage.tsx/CoinsModal.tsx -
createCoinPurchaseIntent/confirmCoinPurchaseIntent(Stripe Apple Pay / Google Pay) — backendcoin-purchase.resolver.js+ frontendCoinExpressCheckout.tsx -
createPaypalCoinOrder/capturePaypalCoinOrder(PayPal) — backendcoin-purchase.resolver.js+ frontendCoinExpressCheckout.tsx -
purchaseCoinsWithSavedPaypal(cobrar una cuenta de PayPal guardada/en vault, sin popup) — backendcoin-purchase.resolver.js+ frontendCoinExpressCheckout.tsx; ver Pagos → PayPal como método de pago guardado -
myCoinBalance/myTransactions— backendcoin-transaction.resolver.js+ frontendCoinTransactionsPage.tsx -
sendTip— resolver conectado encoin-tip.resolver.js;handleSendTipdeChatView.tsxlo invoca víaTipModal(corregido — ver arriba) -
purchaseMessage/unlockMessage— conectado de punta a punta; backendmessage-purchase.resolver.js+ frontendChatView.tsx -
sendCoinsViaMessage— conectado de punta a punta; backendmessage.resolver.js+ frontendChatView.tsx/CoinModal.tsx -
adminCreateCoinPackage/adminUpdateCoinPackage/adminActivateCoinPackage/adminDeactivateCoinPackage/adminDeleteCoinPackage— conectado de punta a punta; backendadmin/coin-package-admin.resolver.js+ página/coinsde frontend-admin (super_admin) -
adminPlatformWallet/adminGetUserCoinBalance/adminTopUpWallet/adminTransferCoinsToUser/adminTakeCoinsFromUser— conectado de punta a punta; backendadmin/platform-wallet.resolver.js+UserCoinsCard.tsxde frontend-admin en la página de detalle de usuario (super_admin) - Monedas multiplataforma —
platform(web/ios/android) + campos de transacción de tienda enCoinPurchase,apple_product_id/google_product_idenCoinPackage, y precio web regional (coin_package_price); migraciones20260730130000/130100/130200 - Validación de recibos IAP nativos —
services/iap/apple-iap.service.js(App Store Server API) +services/iap/google-iap.service.js(Play Developer API);redeemAppleCoinPurchase/redeemGoogleCoinPurchaseencoin-purchase.resolver.js, idempotente por(platform, store_transaction_id) - Webhooks de reembolso de tiendas —
/api/webhooks/apple/iap(App Store Server Notifications V2, JWS/x5c verificado) +/api/webhooks/google/rtdn(RTDN de Pub/Sub, OIDC verificado) →refundIapPurchasedescuenta monedas - Admin: product IDs de tienda + precios regionales —
adminAddCoinPackagePrice/adminRemoveCoinPackagePrice+ inputsappleProductId/googleProductId, conectado en/coinsde frontend-admin - Flujo de compra en app nativa (iOS/Android) — PENDIENTE. El backend + GraphQL están listos; falta que las apps compren el producto de tienda (StoreKit 2 / Play Billing) y luego llamen a la mutation
redeem*CoinPurchase.apps/iosse deja intacto por ahora — ver "Compras dentro de la app (iOS / Android)" abajo.
Compras dentro de la app (iOS / Android)
Las monedas se venden en web (Stripe/PayPal) y, con este trabajo, dentro de las apps nativas mediante la compra in-app de cada tienda, respetando los tiers de precio localizados y la comisión del 15–30% de Apple/Google. Una moneda es universal: un paquete acredita las mismas monedas en toda plataforma; solo el precio de compra se localiza — móvil por las tiendas automáticamente, web vía coin_package_price.
Backend + GraphQL (hecho):
CoinPackage.appleProductId/googleProductIdmapean un paquete a su producto de App Store / Play. Los precios web regionales viven encoin_package_price(por país/moneda; paísnull= default de esa moneda).- La app completa la compra de forma nativa y luego llama a
redeemAppleCoinPurchase(productId, transactionId)oredeemGoogleCoinPurchase(productId, purchaseToken). El server valida el recibo (services/iap/*), mapea producto → paquete y acredita las monedas exactamente una vez (índice único parcial en(platform, store_transaction_id)). - Reembolsos/revocaciones regresan por los webhooks (
refundIapPurchase) y descuentan las monedas. - El payout a creadores sigue siendo una tasa global fija (
CoinCashout) — solo el precio de compra se localiza. - Config (env): Apple
APPLE_IAP_ISSUER_ID/APPLE_IAP_KEY_ID/APPLE_IAP_PRIVATE_KEY/APPLE_IAP_BUNDLE_ID; GoogleGOOGLE_IAP_PACKAGE_NAME/GOOGLE_IAP_SERVICE_ACCOUNT_KEY; endurecimiento de webhooksAPPLE_IAP_ROOT_CERT,GOOGLE_RTDN_AUDIENCE,GOOGLE_RTDN_SA_EMAIL.
⏳ PENDIENTE — integración del cliente nativo (apps/ios, y Android si/cuando exista):
- Aún no implementado;
apps/iosse deja intacto por ahora. La app iOS necesita un flujo de compra StoreKit 2 que, al completarse, llame aredeemAppleCoinPurchasea través del módulo generadoClosegramGraphQL. Android necesita el equivalente con Play Billing. - Configuración del lado de tienda también requerida antes de lanzar: crear los productos consumibles de monedas en App Store Connect / Play Console (con sus tiers de precio localizados) y pegar cada
productIden su paquete desde la pantalla admin/coins.
Paquetes de monedas (CoinPackage)
| Field | Description |
|---|---|
coinAmount | Monedas base del paquete |
bonusCoins | Monedas de bonificación adicionales |
totalCoins | coinAmount + bonusCoins |
price | Precio en dinero real |
currency | Código de moneda (por ejemplo, USD) |
isPopular | Destacado como opción popular |
pricePerCoin | Precio calculado por moneda |
coinPackages lista todos los paquetes disponibles. popularCoinPackages filtra los marcados como populares — útil para una fila de "Destacados". bestValueCoinPackage devuelve el único paquete con el pricePerCoin más bajo — úsalo para una insignia de "Mejor valor".
query CoinPackages { coinPackages { id name totalCoins price currency isPopular } }
query PopularPackages { popularCoinPackages { id name totalCoins price } }
query BestValuePackage { bestValueCoinPackage { id name totalCoins price pricePerCoin } }
Compra de monedas (CoinPurchase)
Las compras de monedas vinculan un CoinPackage con una PaymentTransaction (Stripe o, desde la adición del flujo de express-checkout descrito abajo, PayPal — PaymentTransaction.provider es 'stripe' o 'paypal').
purchaseCoinsWithPayment cobra el paymentMethodId indicado y acredita las monedas inmediatamente si tiene éxito. Envía customCoins en lugar de coinPackageId para comprar una cantidad arbitraria de monedas (por ejemplo, para llegar exactamente a un precio de desbloqueo). Envía savePaymentMethod: true para guardar la tarjeta para uso futuro. La respuesta incluye el nuevo coinAmount, cualquier bonusCoins y el status final.
mutation PurchaseCoins(
$coinPackageId: ID!
$paymentMethodId: ID!
$customCoins: Int # Optional: skip a package and buy an exact amount
$savePaymentMethod: Boolean
) {
purchaseCoinsWithPayment(
coinPackageId: $coinPackageId
paymentMethodId: $paymentMethodId
customCoins: $customCoins
savePaymentMethod: $savePaymentMethod
) {
success message
purchase { id coinAmount bonusCoins amount status completedAt }
}
}
Consultas de compras
myCoinPurchases lista el historial de compras del usuario, de la más reciente a la más antigua. myPurchaseHistory es un alias que devuelve la misma forma de datos. coinPurchaseStats devuelve contadores agregados de por vida sin paginar — úsalo para el resumen de la billetera en el perfil.
query MyCoinPurchases($limit: Int) { myCoinPurchases(limit: $limit) { id coinAmount amount status createdAt } }
query PurchaseHistory($limit: Int) { myPurchaseHistory(limit: $limit) { id coinAmount amount status } }
query PurchaseStats { coinPurchaseStats { totalPurchases totalCoinsPurchased totalAmountSpent } }
Valores de CoinPurchaseStatus: pending / completed / failed / refunded.
refundCoinPurchase(purchaseId) revierte una compra completada y deduce las monedas.
Métodos de pago alternativos
Además de purchaseCoinsWithPayment (cobrando una tarjeta de Stripe guardada/nueva), las monedas se pueden comprar con Apple Pay, Google Pay o PayPal vía CoinExpressCheckout.tsx:
createCoinPurchaseIntent(coinPackageId, customCoins)crea un Stripe PaymentIntent y devuelve unStripeCoinIntent(clientSecret,paymentIntentId,purchaseId,amount,currency,coinAmount) que el navegador confirma vía el Express Checkout Element de Stripe.confirmCoinPurchaseIntent(paymentIntentId)es un respaldo de confirmación del lado del cliente para el webhook y es idempotente.createPaypalCoinOrder(coinPackageId, customCoins)crea una orden de PayPal y devuelve unPaypalCoinOrder(orderId,purchaseId,amount,currency,coinAmount) para que los botones de PayPal la aprueben.capturePaypalCoinOrder(orderId)captura la orden aprobada y acredita las monedas; es idempotente.
mutation CreateCoinPurchaseIntent($coinPackageId: ID!, $customCoins: Int) {
createCoinPurchaseIntent(coinPackageId: $coinPackageId, customCoins: $customCoins) {
clientSecret paymentIntentId purchaseId amount currency coinAmount
}
}
mutation CreatePaypalCoinOrder($coinPackageId: ID!, $customCoins: Int) {
createPaypalCoinOrder(coinPackageId: $coinPackageId, customCoins: $customCoins) {
orderId purchaseId amount currency coinAmount
}
}
Si el usuario ya tiene una cuenta de PayPal guardada como método de pago (ver Pagos → PayPal como método de pago guardado), CoinExpressCheckout.tsx muestra en su lugar un botón de un solo clic "Pay with PayPal (email)", respaldado por purchaseCoinsWithSavedPaypal — sin popup, ya que la aprobación del comprador ya se capturó al guardar el método:
mutation PurchaseCoinsWithSavedPaypal($coinPackageId: ID!, $paymentMethodId: ID!, $customCoins: Int) {
purchaseCoinsWithSavedPaypal(
coinPackageId: $coinPackageId
paymentMethodId: $paymentMethodId
customCoins: $customCoins
) {
success message
purchase { id status }
}
}
Saldo y transacciones
myCoinBalance devuelve el estado actual de la billetera. lifetimeEarned cuenta las monedas recibidas por propinas y suscripciones; lifetimePurchased cuenta las monedas compradas con dinero real; lifetimeSpent cuenta las monedas gastadas en contenido o transferencias.
myTransactions devuelve el libro mayor completo, del más reciente al más antiguo. Cada entrada tiene un type (ver el enum abajo) y un amount con signo (positivo = crédito, negativo = débito).
myTransactionStats es una agregación rápida para la pantalla de resumen de la billetera.
query MyCoinBalance {
myCoinBalance { balance lifetimeEarned lifetimePurchased lifetimeSpent }
}
query MyTransactions($limit: Int, $offset: Int) {
myTransactions(limit: $limit, offset: $offset) {
id type amount description createdAt
}
}
query MyTransactionStats {
myTransactionStats { totalTransactions totalCredits totalDebits currentBalance }
}
Tipos de transacción
enum CoinTransactionType {
purchase # Coin package purchase
tip # Tip (legacy single-row path via coin-transaction.manager#sendTip)
reward # System/admin reward (e.g. platform wallet transfer to a user)
tip_sent # Tip sent (coin-tip.manager separate debit/credit rows)
tip_received # Tip received (coin-tip.manager separate debit/credit rows)
post_purchase # Buyer side of an exclusive-post coin purchase
conversation_subscription # Buyer side of a paid conversation subscription
post_sale # Creator earnings from a post sale
subscription_revenue # Creator earnings from a subscription
product_sale # Creator earnings from a product sale
refund # Refunded transaction
cashout_requested # Coins reserved for a pending cashout
cashout_reversed # Cashout reversed
cashout_canceled # Cashout canceled
withdrawal # Withdrawal
transfer # Direct transfer (e.g. sendCoinsViaMessage, or a platform wallet take from a user)
}
Este es un conjunto más amplio de lo que necesita distinguir la interfaz de la billetera — myTransactions/myTransactionStats tratan cualquier amount positivo como crédito y cualquier amount negativo como débito sin importar el type.
Propinas (CoinTip)
Las propinas se pueden enviar a cualquier tipo de contenido. contentType es una cadena como "post", "message" o "profile". El campo message permite que el remitente adjunte una breve nota a la propina.
mutation SendTip($input: CoinTipCreateInput!) {
sendTip(input: $input) {
id amount message
receiver { username }
}
}
# contentType: "post" | "message" | "profile" | etc.
Consultas de propinas
mySentTips y myReceivedTips alimentan la pestaña "Propinas" de la billetera. contentTips lista todas las propinas de una pieza de contenido específica (por ejemplo, todas las propinas de un post). contentTipTotal es una agregación de un solo número — úsala para la etiqueta "🎁 125 coins" en un post. topTippers lista a los mayores seguidores de un creador, ordenados por el monto de propinas de por vida. myTipStats le da al usuario actual un resumen de por vida de su actividad de propinas.
query MySentTips($limit: Int) { mySentTips(limit: $limit) { id amount receiver { username } } }
query MyReceivedTips($limit: Int) { myReceivedTips(limit: $limit) { id amount sender { username } } }
query ContentTips($contentType: String!, $contentId: ID!) { contentTips(contentType: $contentType, contentId: $contentId) { id amount } }
query ContentTipTotal($contentType: String!, $contentId: ID!) { contentTipTotal(contentType: $contentType, contentId: $contentId) }
query TopTippers($userId: ID!) { topTippers(userId: $userId) { user { username } totalAmount tipCount } }
query MyTipStats { myTipStats { totalSent totalReceived totalSentAmount totalReceivedAmount } }
Transferencia de monedas vía mensaje
Las monedas se pueden enviar directamente dentro de una conversación de chat. La respuesta incluye el balanceAfter actualizado para que la interfaz pueda actualizar la visualización de la billetera de inmediato sin una consulta de saldo separada.
mutation SendCoinsViaMessage(
$conversationId: ID!
$recipientId: ID
$amount: Int!
$message: String
) {
sendCoinsViaMessage(
conversationId: $conversationId
recipientId: $recipientId
amount: $amount
message: $message
) {
success
transaction { amount balanceAfter }
message { id messageType }
}
}
Administración: gestión de paquetes de monedas
Los paquetes se gestionan desde la página /coins en frontend-admin, restringida a super_admin. Estas operaciones viven en el esquema ADMIN (campos admin*, accesibles en /admin/graphql) en lugar del esquema del cliente — los campos createCoinPackage/updateCoinPackage/activateCoinPackage/deactivateCoinPackage/deleteCoinPackage (sin el prefijo admin) que antes estaban en coin-package.resolver.js fueron eliminados porque sus resolvers requerían context.admin, que el endpoint del cliente nunca poblaba; adminGetCoinPackages/admin*CoinPackage en coin-package-admin.resolver.js son los equivalentes accesibles y reutilizan la misma lógica de coin-package.manager.js.
adminDeactivateCoinPackage oculta el paquete de la interfaz de compra sin eliminar los registros históricos de compras. adminDeleteCoinPackage lo elimina permanentemente.
query AdminCoinPackages { adminGetCoinPackages { id name totalCoins price isActive } }
mutation AdminCreateCoinPackage($input: CoinPackageCreateInput!) { adminCreateCoinPackage(input: $input) { id } }
mutation AdminUpdateCoinPackage($id: ID!, $input: CoinPackageUpdateInput!) { adminUpdateCoinPackage(id: $id, input: $input) { id } }
mutation AdminActivateCoinPackage($id: ID!) { adminActivateCoinPackage(id: $id) { success } }
mutation AdminDeactivateCoinPackage($id: ID!) { adminDeactivateCoinPackage(id: $id) { success } }
mutation AdminDeleteCoinPackage($id: ID!) { adminDeleteCoinPackage(id: $id) { success } }
Administración: billetera de monedas de la plataforma
Una única PlatformWallet a nivel de plataforma (una sola fila, walletKey: 'platform') que opera el administrador, restringida a super_admin. Las monedas se mueven entre la billetera y el saldo de un usuario, conservando el total — no se acuña nada salvo mediante una recarga explícita:
adminTopUpWallet(amount, reason)acuña monedas en la billetera.adminTransferCoinsToUser(userId, amount, reason)mueve monedas de la billetera al usuario (falla si la billetera no tiene suficientes); esto acredita al usuario mediante unaCoinTransactionde tiporeward.adminTakeCoinsFromUser(userId, amount, reason)mueve monedas del usuario a la billetera, limitado al saldo del usuario; esto debita al usuario mediante unaCoinTransactionde tipotransfer.adminPlatformWalletdevuelve elbalance/lifetimeIn/lifetimeOutde la billetera.adminGetUserCoinBalance(userId)devuelve el saldo de monedas de un único usuario.
Las monedas de tipo reward otorgadas por el administrador mediante adminTransferCoinsToUser no son elegibles para retiro, por lo que esto nunca crea dinero retirable. El componente UserCoinsCard.tsx en la página de detalle de usuario del admin (/users/[id]) es la interfaz para todo esto.
query AdminPlatformWallet { adminPlatformWallet { balance lifetimeIn lifetimeOut } }
query AdminUserCoinBalance($userId: ID!) { adminGetUserCoinBalance(userId: $userId) }
mutation AdminTopUpWallet($amount: Int!, $reason: String) {
adminTopUpWallet(amount: $amount, reason: $reason) { balance lifetimeIn lifetimeOut }
}
mutation AdminTransferCoinsToUser($userId: ID!, $amount: Int!, $reason: String) {
adminTransferCoinsToUser(userId: $userId, amount: $amount, reason: $reason) {
userId amount userBalance walletBalance
}
}
mutation AdminTakeCoinsFromUser($userId: ID!, $amount: Int!, $reason: String) {
adminTakeCoinsFromUser(userId: $userId, amount: $amount, reason: $reason) {
userId amount userBalance walletBalance
}
}