Saltar al contenido principal

Monedas y propinas — Referencia técnica

← Volver a Monedas y propinas

Dónde vive esto

Backend

Frontend

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) — backend coin-purchase.resolver.js + frontend CoinsPage.tsx / CoinsModal.tsx
  • createCoinPurchaseIntent / confirmCoinPurchaseIntent (Stripe Apple Pay / Google Pay) — backend coin-purchase.resolver.js + frontend CoinExpressCheckout.tsx
  • createPaypalCoinOrder / capturePaypalCoinOrder (PayPal) — backend coin-purchase.resolver.js + frontend CoinExpressCheckout.tsx
  • purchaseCoinsWithSavedPaypal (cobrar una cuenta de PayPal guardada/en vault, sin popup) — backend coin-purchase.resolver.js + frontend CoinExpressCheckout.tsx; ver Pagos → PayPal como método de pago guardado
  • myCoinBalance / myTransactions — backend coin-transaction.resolver.js + frontend CoinTransactionsPage.tsx
  • sendTip — resolver conectado en coin-tip.resolver.js; handleSendTip de ChatView.tsx lo invoca vía TipModal (corregido — ver arriba)
  • purchaseMessage / unlockMessage — conectado de punta a punta; backend message-purchase.resolver.js + frontend ChatView.tsx
  • sendCoinsViaMessage — conectado de punta a punta; backend message.resolver.js + frontend ChatView.tsx / CoinModal.tsx
  • adminCreateCoinPackage / adminUpdateCoinPackage / adminActivateCoinPackage / adminDeactivateCoinPackage / adminDeleteCoinPackage — conectado de punta a punta; backend admin/coin-package-admin.resolver.js + página /coins de frontend-admin (super_admin)
  • adminPlatformWallet / adminGetUserCoinBalance / adminTopUpWallet / adminTransferCoinsToUser / adminTakeCoinsFromUser — conectado de punta a punta; backend admin/platform-wallet.resolver.js + UserCoinsCard.tsx de frontend-admin en la página de detalle de usuario (super_admin)
  • Monedas multiplataforma — platform (web/ios/android) + campos de transacción de tienda en CoinPurchase, apple_product_id/google_product_id en CoinPackage, y precio web regional (coin_package_price); migraciones 20260730130000 / 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 / redeemGoogleCoinPurchase en coin-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) → refundIapPurchase descuenta monedas
  • Admin: product IDs de tienda + precios regionales — adminAddCoinPackagePrice / adminRemoveCoinPackagePrice + inputs appleProductId/googleProductId, conectado en /coins de 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/ios se 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 / googleProductId mapean un paquete a su producto de App Store / Play. Los precios web regionales viven en coin_package_price (por país/moneda; país null = default de esa moneda).
  • La app completa la compra de forma nativa y luego llama a redeemAppleCoinPurchase(productId, transactionId) o redeemGoogleCoinPurchase(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; Google GOOGLE_IAP_PACKAGE_NAME / GOOGLE_IAP_SERVICE_ACCOUNT_KEY; endurecimiento de webhooks APPLE_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/ios se deja intacto por ahora. La app iOS necesita un flujo de compra StoreKit 2 que, al completarse, llame a redeemAppleCoinPurchase a través del módulo generado ClosegramGraphQL. 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 productId en su paquete desde la pantalla admin /coins.

Paquetes de monedas (CoinPackage)

FieldDescription
coinAmountMonedas base del paquete
bonusCoinsMonedas de bonificación adicionales
totalCoinscoinAmount + bonusCoins
pricePrecio en dinero real
currencyCódigo de moneda (por ejemplo, USD)
isPopularDestacado como opción popular
pricePerCoinPrecio 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 un StripeCoinIntent (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 un PaypalCoinOrder (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 una CoinTransaction de tipo reward.
  • adminTakeCoinsFromUser(userId, amount, reason) mueve monedas del usuario a la billetera, limitado al saldo del usuario; esto debita al usuario mediante una CoinTransaction de tipo transfer.
  • adminPlatformWallet devuelve el balance/lifetimeIn/lifetimeOut de 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
}
}