Skip to main content

Coins & Tips — Technical Reference

← Back to Coins & Tips

Where this lives

Backend

Frontend

sendTip is wired end-to-end via ChatView.tsx's handleSendTip, invoked from a TipModal (this doc previously said no frontend UI existed for tips — corrected).

Technical implementation checklist

  • purchaseCoinsWithPayment (Stripe saved card) — 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 (charge a saved/vaulted PayPal account, no popup) — backend coin-purchase.resolver.js + frontend CoinExpressCheckout.tsx; see Payments → PayPal as a saved payment method
  • myCoinBalance / myTransactions — backend coin-transaction.resolver.js + frontend CoinTransactionsPage.tsx
  • sendTip — resolver wired in coin-tip.resolver.js; ChatView.tsx's handleSendTip calls it via TipModal (corrected — see above)
  • purchaseMessage / unlockMessage — wired end-to-end; backend message-purchase.resolver.js + frontend ChatView.tsx
  • sendCoinsViaMessage — wired end-to-end; backend message.resolver.js + frontend ChatView.tsx / CoinModal.tsx
  • adminCreateCoinPackage / adminUpdateCoinPackage / adminActivateCoinPackage / adminDeactivateCoinPackage / adminDeleteCoinPackage — wired end-to-end; backend admin/coin-package-admin.resolver.js + frontend-admin /coins page (super_admin)
  • adminPlatformWallet / adminGetUserCoinBalance / adminTopUpWallet / adminTransferCoinsToUser / adminTakeCoinsFromUser — wired end-to-end; backend admin/platform-wallet.resolver.js + frontend-admin UserCoinsCard.tsx on the user detail page (super_admin)
  • Multi-platform coins — platform (web/ios/android) + store transaction fields on CoinPurchase, apple_product_id/google_product_id on CoinPackage, and regional web pricing (coin_package_price); migrations 20260730130000 / 130100 / 130200
  • Native IAP receipt validation — services/iap/apple-iap.service.js (App Store Server API) + services/iap/google-iap.service.js (Play Developer API); redeemAppleCoinPurchase / redeemGoogleCoinPurchase in coin-purchase.resolver.js, idempotent per (platform, store_transaction_id)
  • Store refund webhooks — /api/webhooks/apple/iap (App Store Server Notifications V2, JWS/x5c-verified) + /api/webhooks/google/rtdn (Pub/Sub RTDN, OIDC-verified) → refundIapPurchase deducts coins
  • Admin: store product IDs + regional prices — adminAddCoinPackagePrice / adminRemoveCoinPackagePrice + appleProductId/googleProductId inputs, wired in frontend-admin /coins
  • Native app purchase flow (iOS/Android) — PENDING. Backend + GraphQL are ready; the mobile apps still need to buy the mapped store product (StoreKit 2 / Play Billing) and then call the redeem*CoinPurchase mutation. apps/ios is intentionally left untouched for now — see "Native in-app purchases" below.

Native in-app purchases (iOS / Android)

Coins are sold on web (Stripe/PayPal) and, as of this work, inside the native apps via each store's own in-app purchase, so Apple/Google's localized price tiers and 15–30% cut are respected. A coin is universal: a package credits the same coins on every platform; only the purchase price is localized — mobile by the stores automatically, web via coin_package_price.

Backend + GraphQL (done):

  • CoinPackage.appleProductId / googleProductId map a package to its App Store / Play product. Regional web prices live in coin_package_price (per country/currency; null country = default for that currency).
  • The app completes the purchase natively, then calls redeemAppleCoinPurchase(productId, transactionId) or redeemGoogleCoinPurchase(productId, purchaseToken). The server validates the receipt (services/iap/*), maps product → package, and credits coins exactly once (partial unique index on (platform, store_transaction_id)).
  • Refunds/revocations flow back through the webhooks (refundIapPurchase) and deduct the coins.
  • Payout to creators stays a fixed global rate (CoinCashout) — only the buy price is localized.
  • 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; webhook hardening APPLE_IAP_ROOT_CERT, GOOGLE_RTDN_AUDIENCE, GOOGLE_RTDN_SA_EMAIL.

⏳ PENDING — native client integration (apps/ios, and Android if/when it exists):

  • Not yet implemented; apps/ios is deliberately untouched for now. The iOS app needs a StoreKit 2 purchase flow that, on success, calls redeemAppleCoinPurchase through the generated ClosegramGraphQL module. Android needs the equivalent with Play Billing.
  • Store-side setup also required before shipping: create the consumable coin products in App Store Connect / Play Console (with their localized price tiers) and paste each productId into its package from the admin /coins screen.

Coin packages (CoinPackage)

FieldDescription
coinAmountBase coins in the package
bonusCoinsAdditional bonus coins
totalCoinscoinAmount + bonusCoins
priceReal-money price
currencyCurrency code (e.g. USD)
isPopularHighlighted as a popular choice
pricePerCoinCalculated price per coin

coinPackages lists all available packages. popularCoinPackages filters to those flagged as popular — useful for a "Featured" row. bestValueCoinPackage returns the single package with the lowest pricePerCoin — use this for a "Best value" badge.

query CoinPackages { coinPackages { id name totalCoins price currency isPopular } }
query PopularPackages { popularCoinPackages { id name totalCoins price } }
query BestValuePackage { bestValueCoinPackage { id name totalCoins price pricePerCoin } }

Purchasing coins (CoinPurchase)

Coin purchases link a CoinPackage to a PaymentTransaction (Stripe or, since the addition of the express-checkout flow below, PayPal — PaymentTransaction.provider is 'stripe' or 'paypal').

purchaseCoinsWithPayment charges the given paymentMethodId and credits the coins immediately on success. Pass customCoins instead of coinPackageId to buy an arbitrary number of coins (e.g. to top up to an exact unlock price). Pass savePaymentMethod: true to add the card for future use. The response includes the new coinAmount, any bonusCoins, and the final status.

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 }
}
}

Purchase queries

myCoinPurchases lists the user's purchase history, newest first. myPurchaseHistory is an alias that returns the same shape. coinPurchaseStats returns lifetime aggregate counters without paginating — use it for the profile wallet summary.

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 } }

CoinPurchaseStatus values: pending / completed / failed / refunded.

refundCoinPurchase(purchaseId) reverses a completed purchase and deducts the coins.

Alternative payment methods

Besides purchaseCoinsWithPayment (charging a saved/new Stripe card), coins can be bought with Apple Pay, Google Pay, or PayPal via CoinExpressCheckout.tsx:

  • createCoinPurchaseIntent(coinPackageId, customCoins) creates a Stripe PaymentIntent and returns a StripeCoinIntent (clientSecret, paymentIntentId, purchaseId, amount, currency, coinAmount) that the browser confirms via Stripe's Express Checkout Element. confirmCoinPurchaseIntent(paymentIntentId) is a client-side confirmation fallback to the webhook and is idempotent.
  • createPaypalCoinOrder(coinPackageId, customCoins) creates a PayPal order and returns a PaypalCoinOrder (orderId, purchaseId, amount, currency, coinAmount) for the PayPal Buttons to approve. capturePaypalCoinOrder(orderId) captures the approved order and credits the coins; idempotent.
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
}
}

If the user already has a PayPal account saved as a payment method (see Payments → PayPal as a saved payment method), CoinExpressCheckout.tsx shows a one-click "Pay with PayPal (email)" button instead, backed by purchaseCoinsWithSavedPaypal — no popup, since the buyer's approval was already captured when the method was saved:

mutation PurchaseCoinsWithSavedPaypal($coinPackageId: ID!, $paymentMethodId: ID!, $customCoins: Int) {
purchaseCoinsWithSavedPaypal(
coinPackageId: $coinPackageId
paymentMethodId: $paymentMethodId
customCoins: $customCoins
) {
success message
purchase { id status }
}
}

Balance & transactions

myCoinBalance returns the current wallet state. lifetimeEarned counts coins received from tips and subscriptions; lifetimePurchased counts coins bought with real money; lifetimeSpent counts coins spent on content or transfers.

myTransactions returns the full ledger, newest first. Each entry has a type (see enum below) and a signed amount (positive = credit, negative = debit).

myTransactionStats is a fast aggregation for the wallet overview screen.

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 }
}

Transaction types

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)
}

This is a broader set than the wallet UI needs to distinguish — myTransactions/myTransactionStats treat any positive amount as a credit and any negative amount as a debit regardless of type.

Tips (CoinTip)

Tips can be sent to any type of content. contentType is a string like "post", "message", or "profile". The message field lets the sender attach a short note to the tip.

mutation SendTip($input: CoinTipCreateInput!) {
sendTip(input: $input) {
id amount message
receiver { username }
}
}

# contentType: "post" | "message" | "profile" | etc.

Tip queries

mySentTips and myReceivedTips power the "Tips" tab in the wallet. contentTips lists all tips on a specific piece of content (e.g. all tips on a post). contentTipTotal is a single-number aggregation — use it for the "🎁 125 coins" label on a post. topTippers lists a creator's biggest supporters, sorted by lifetime tip amount. myTipStats gives the current user a lifetime summary of tipping activity.

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 } }

Coin transfer via message

Coins can be sent directly inside a chat conversation. The response includes the updated balanceAfter so the UI can update the wallet display immediately without a separate balance query.

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 }
}
}

Admin: coin package management

Packages are managed from the /coins page in frontend-admin, gated to super_admin. These operations live on the ADMIN schema (admin* fields, reachable at /admin/graphql) rather than the client schema — the non-admin createCoinPackage/updateCoinPackage/activateCoinPackage/deactivateCoinPackage/deleteCoinPackage fields that used to sit on coin-package.resolver.js were removed because their resolvers required context.admin, which the client endpoint never populated; adminGetCoinPackages/admin*CoinPackage in coin-package-admin.resolver.js are the reachable equivalents and reuse the same coin-package.manager.js logic.

adminDeactivateCoinPackage hides the package from the purchase UI without deleting historical purchase records. adminDeleteCoinPackage permanently removes it.

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 } }

Admin: platform coin wallet

A single platform-wide PlatformWallet (one row, walletKey: 'platform') that the admin operates, gated to super_admin. Coins move between the wallet and a user's balance, conserving the total — nothing is minted except an explicit top-up:

  • adminTopUpWallet(amount, reason) mints coins into the wallet.
  • adminTransferCoinsToUser(userId, amount, reason) moves coins wallet → user (fails if the wallet doesn't have enough); this credits the user via a reward-type CoinTransaction.
  • adminTakeCoinsFromUser(userId, amount, reason) moves coins user → wallet, capped at the user's balance; this debits the user via a transfer-type CoinTransaction.
  • adminPlatformWallet returns the wallet's balance/lifetimeIn/lifetimeOut. adminGetUserCoinBalance(userId) returns a single user's coin balance.

Admin-granted reward coins from adminTransferCoinsToUser are not cashout-eligible, so this never creates withdrawable money. The UserCoinsCard.tsx component on the admin user detail page (/users/[id]) is the UI for all of this.

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
}
}