Coins & Tips — Technical Reference
Where this lives
Backend
apps/backend/graphql/resolvers/coin-package.resolver.js— public coin package listing only (coinPackages/coinPackage/popularCoinPackages/bestValueCoinPackage/allCoinPackages); the CRUD mutations that used to live here were removed — see the admin resolver belowapps/backend/graphql/resolvers/admin/coin-package-admin.resolver.js— admin-namespaced coin package CRUD (adminGetCoinPackages/adminCreateCoinPackage/adminUpdateCoinPackage/adminActivateCoinPackage/adminDeactivateCoinPackage/adminDeleteCoinPackage), gated bycontext.admin+ super_admin; delegates to the samecoin-package.manager.jsapps/backend/graphql/resolvers/coin-purchase.resolver.js—purchaseCoinsWithPayment(saved card), the Stripe PaymentIntent flow (createCoinPurchaseIntent/confirmCoinPurchaseIntent), the PayPal flow (createPaypalCoinOrder/capturePaypalCoinOrder), and purchase history/stats resolversapps/backend/graphql/resolvers/coin-transaction.resolver.js— wallet balance and transaction ledger resolversapps/backend/graphql/resolvers/coin-tip.resolver.js— sendTip and tip history/stats resolversapps/backend/managers/coin-managers/coin-purchase.manager.js— purchase flow — charges the payment method (Stripe saved card, Stripe PaymentIntent for wallets, or PayPal) and credits coinsapps/backend/managers/coin-managers/coin-tip.manager.js— tip sending/aggregation business logicapps/backend/managers/coin-managers/coin-transaction.manager.js— balance and ledger business logicapps/backend/managers/coin-managers/platform-wallet.manager.js— single platform-wide coin wallet the admin operates (top up, transfer to a user, take from a user); user-side movements go throughcoin-transaction.manager.jsso balances/ledger stay consistentapps/backend/graphql/resolvers/admin/platform-wallet.resolver.js— admin platform-wallet resolvers (adminPlatformWallet/adminGetUserCoinBalance/adminTopUpWallet/adminTransferCoinsToUser/adminTakeCoinsFromUser), super_admin onlyapps/backend/graphql/resolvers/message.resolver.js— sendCoinsViaMessage — coin transfer embedded in a chat messageapps/backend/services/stripe— Stripe integration used for card payments and PaymentIntents (Apple Pay / Google Pay) behind a coin purchaseapps/backend/services/paypal— PayPal order/capture integration used for PayPal coin purchases
Frontend
apps/frontend-nextjs/src/page-components/CoinsPage.tsx— wallet balance display and coin package purchase flow; rendersCoinExpressCheckout(Apple Pay / Google Pay / PayPal) aboveStripeCheckout(saved/new card)apps/frontend-nextjs/src/components/payment/CoinExpressCheckout.tsx— Stripe Express Checkout Element (Apple Pay / Google Pay) and PayPal Buttons for buying coins; each method only renders when configured (NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,NEXT_PUBLIC_PAYPAL_CLIENT_ID)apps/frontend-nextjs/src/page-components/CoinTransactionsPage.tsx— purchase/transaction history listapps/frontend-nextjs/src/components/coins/CoinsModal.tsx— coin purchase modalapps/frontend-nextjs/src/app/coins/page.tsx— coins routeapps/frontend-nextjs/src/app/coins/transactions/page.tsx— transaction history routeapps/frontend-admin/src/app/coins/page.tsx— admin coin package management UI (list/create/update/activate/deactivate/delete), calls theadmin*CoinPackageoperations aboveapps/frontend-admin/src/components/UserCoinsCard.tsx— on the admin user detail page (apps/frontend-admin/src/app/users/[id]/page.tsx), shows the user's coin balance and the platform wallet balance, and lets a super_admin transfer coins to/take coins from the user or top up the platform wallet
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) — 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(charge a saved/vaulted PayPal account, no popup) — backendcoin-purchase.resolver.js+ frontendCoinExpressCheckout.tsx; see Payments → PayPal as a saved payment method -
myCoinBalance/myTransactions— backendcoin-transaction.resolver.js+ frontendCoinTransactionsPage.tsx -
sendTip— resolver wired incoin-tip.resolver.js;ChatView.tsx'shandleSendTipcalls it viaTipModal(corrected — see above) -
purchaseMessage/unlockMessage— wired end-to-end; backendmessage-purchase.resolver.js+ frontendChatView.tsx -
sendCoinsViaMessage— wired end-to-end; backendmessage.resolver.js+ frontendChatView.tsx/CoinModal.tsx -
adminCreateCoinPackage/adminUpdateCoinPackage/adminActivateCoinPackage/adminDeactivateCoinPackage/adminDeleteCoinPackage— wired end-to-end; backendadmin/coin-package-admin.resolver.js+ frontend-admin/coinspage (super_admin) -
adminPlatformWallet/adminGetUserCoinBalance/adminTopUpWallet/adminTransferCoinsToUser/adminTakeCoinsFromUser— wired end-to-end; backendadmin/platform-wallet.resolver.js+ frontend-adminUserCoinsCard.tsxon the user detail page (super_admin) - Multi-platform coins —
platform(web/ios/android) + store transaction fields onCoinPurchase,apple_product_id/google_product_idonCoinPackage, and regional web pricing (coin_package_price); migrations20260730130000/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/redeemGoogleCoinPurchaseincoin-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) →refundIapPurchasededucts coins - Admin: store product IDs + regional prices —
adminAddCoinPackagePrice/adminRemoveCoinPackagePrice+appleProductId/googleProductIdinputs, 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*CoinPurchasemutation.apps/iosis 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/googleProductIdmap a package to its App Store / Play product. Regional web prices live incoin_package_price(per country/currency;nullcountry = default for that currency).- The app completes the purchase natively, then calls
redeemAppleCoinPurchase(productId, transactionId)orredeemGoogleCoinPurchase(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; GoogleGOOGLE_IAP_PACKAGE_NAME/GOOGLE_IAP_SERVICE_ACCOUNT_KEY; webhook hardeningAPPLE_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/iosis deliberately untouched for now. The iOS app needs a StoreKit 2 purchase flow that, on success, callsredeemAppleCoinPurchasethrough the generatedClosegramGraphQLmodule. 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
productIdinto its package from the admin/coinsscreen.
Coin packages (CoinPackage)
| Field | Description |
|---|---|
coinAmount | Base coins in the package |
bonusCoins | Additional bonus coins |
totalCoins | coinAmount + bonusCoins |
price | Real-money price |
currency | Currency code (e.g. USD) |
isPopular | Highlighted as a popular choice |
pricePerCoin | Calculated 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 aStripeCoinIntent(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 aPaypalCoinOrder(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 areward-typeCoinTransaction.adminTakeCoinsFromUser(userId, amount, reason)moves coins user → wallet, capped at the user's balance; this debits the user via atransfer-typeCoinTransaction.adminPlatformWalletreturns the wallet'sbalance/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
}
}