协作头脑风暴
brainstorming
在实现前澄清需求、探索设计选择,并形成清晰的开发方向。
mobile-in-app-purchase【开发与工程】Use this skill when the user says 'in-app purchase', 'IAP', 'subscription', 'consumable', 'StoreKit', 'Play Billing', 'receipt validation', 'restore purchase'. This skill enforces proper purchase flow patterns: product configuration, purchase flow, receipt validation, subscription management, and restore handling. Applies to iOS, Android, Flutter, an…
独立 GitHub 快照 · 自动分类 · 以原作者文档为准
Implement in-app purchases with correct product configuration, purchase flow, receipt validation, subscription management, and restore handling across all mobile platforms.
User request includes: in-app purchase, IAP, subscription, consumable, StoreKit, Play Billing, receipt validation, restore purchase.
A markdown document containing product configuration and type selection, purchase flow implementation, receipt validation (client and server-side), subscription management (grace period, billing retry), restore purchases and entitlement verification, and platform-specific StoreKit / Play Billing APIs.
Code-first. One code block per platform (Swift, Kotlin, Dart/TS) with full purchase and validation flow. Platform divergence points as bullet list. No preamble. No postamble. No explanations.
4096 tokens
What does the user get?
├── One-time permanent unlock (remove ads, full game)
│ └── Non-consumable (restorable, one purchase forever)
├── Depletable resource (coins, gems, energy, lives)
│ └── Consumable (not restorable, can buy again)
├── Time-limited access (premium tier, cloud storage, streaming)
│ └── Auto-renewable subscription (Apple/Google manage billing)
└── Fixed-duration pass (season pass, event ticket)
└── Non-renewing subscription (custom expiry, manage yourself)
Does app have own backend server?
├── Yes → Server-side receipt validation
│ ├── iOS: server POST to Apple VerifyReceipt API
│ └── Android: server call Google Play Developer API with OAuth2
├── No backend but cross-platform → RevenueCat (handles server validation)
└── Prototype only → Client-side StoreKit 2 automatic verification
Target platforms?
├── iOS only → StoreKit 2 (async/await, automatic receipt verification)
├── Android only → Play Billing 6+ (with BillingClient.queryProductDetailsAsync)
├── iOS + Android cross-platform
│ ├── Native per platform → StoreKit 2 + Play Billing 6 (maximum control)
│ └── Unified cross-platform → RevenueCat Purchases SDK
└── Flutter/React Native
├── RevenueCat Flutter/RN SDK (recommended, handles cross-platform)
└── Platform channels with native StoreKit + Play Billing
User subscribing for first time?
├── Free trial (best conversion, no risk)
│ ├── 3-day, 7-day, 14-day, or 30-day
│ └── Higher LTV products → longer trial
├── Pay-as-you-go introductory price (3 months at 50% off)
│ └── Medium price point, want revenue immediately
├── Pay-up-front introductory price (1 year at 40% off annual)
│ └── Annual subscription, high upfront commitment
└── No introductory offer
└── Already discounted product, low price point
Group multiple subscription products:
├── Single tier (one subscription SKU)
│ └── No group needed, just one product
├── Tiered access (Basic, Pro, Enterprise)
│ └── Same group → user can upgrade/downgrade within group
├── Standalone products (Storage 50GB, Storage 200GB)
│ └── One group per product line
└── Content subscriptions (News, Sports, Finance)
└── Separate groups per content category
| Type | Persistence | Restore | Use Case |
|---|---|---|---|
| Consumable | No | No | Coins, gems, lives |
| Non-consumable | Yes | Yes | Remove ads, unlock level |
| Auto-renewable subscription | Yes (while active) | Yes | Premium tier, cloud storage |
| Non-renewing subscription | Yes (custom duration) | Varies | Time-limited pass |
iOS (App Store Connect):
Android (Google Play Console):
import StoreKit
typealias Product = StoreKit.Product
struct StoreService {
static let shared = StoreService()
private var purchasedProductIDs = Set<String>()
func fetchProducts(_ ids: Set<String>) async throws -> [Product] {
return try await Product.products(for: ids)
}
func purchase(_ product: Product) async throws -> PurchaseResult {
let result = try await product.purchase()
switch result {
case .success(let verification):
let transaction = try verification.payloadValue
await deliverPurchase(transaction)
await transaction.finish()
return .success(transaction)
case .userCancelled:
return .cancelled
case .pending:
return .pending // Ask to Buy, family approval
@unknown default:
return .unknown
}
}
private func deliverPurchase(_ transaction: Transaction) async {
purchasedProductIDs.insert(transaction.productID)
if transaction.productType == .nonConsumable {
// Unlock content permanently
} else if transaction.productType == .consumable {
// Add consumable to player inventory
}
}
func checkEntitlements() async {
for await result in Transaction.currentEntitlements {
if case .verified(let transaction) = result {
if transaction.revocationDate == nil {
purchasedProductIDs.insert(transaction.productID)
} else {
purchasedProductIDs.remove(transaction.productID)
}
}
}
}
}
enum PurchaseResult {
case success(Transaction)
case cancelled
case pending
case unknown
}
class BillingRepository(private val context: Context) {
private val billingClient = BillingClient.newBuilder(context)
.setListener { billingResult, purchases ->
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) {
purchases.forEach { purchase ->
when (purchase.purchaseState) {
Purchase.PurchaseState.PURCHASED -> handlePurchase(purchase)
Purchase.PurchaseState.PENDING -> handlePendingPurchase(purchase)
Purchase.PurchaseState.UNSPECIFIED_STATE -> { /* ignore */ }
}
}
}
}
.enablePendingPurchases()
.build()
fun queryProducts(productIds: List<String>, type: ProductType, callback: (List<Product>) -> Unit) {
billingClient.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(billingResult: BillingResult) {
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
val params = QueryProductDetailsParams.newBuilder()
.setProductList(productIds.map { id ->
QueryProductDetailsParams.Product.newBuilder()
.setProductId(id)
.setProductType(type)
.build()
})
.build()
billingClient.queryProductDetailsAsync(params) { _, productDetails ->
callback(productDetails)
}
}
}
override fun onBillingServiceDisconnected() { /* retry connection */ }
})
}
fun purchaseProduct(activity: Activity, product: ProductDetails, offerToken: String?) {
val params = BillingFlowParams.newBuilder()
.setProductDetailsList(listOf(product))
.apply { offerToken?.let { setOfferToken(it) } }
.build()
billingClient.launchBillingFlow(activity, params)
}
private fun handlePurchase(purchase: Purchase) {
// Verify purchase signature client-side (optional pre-check)
val signatureValid = verifyPurchaseSignature(purchase)
if (!signatureValid) return
// Acknowledge purchase (within 3 days or auto-refund)
val params = AcknowledgePurchaseParams.newBuilder()
.setPurchaseToken(purchase.purchaseToken)
.build()
billingClient.acknowledgePurchase(params) { _, _ -> }
// Send to server for verification
Server.verifyPurchase(purchase)
}
fun queryExistingPurchases(type: ProductType, callback: (List<Purchase>) -> Unit) {
billingClient.queryPurchasesAsync(type) { _, purchases ->
callback(purchases)
}
}
}
// Node.js server-side validation
const APPLE_SHARED_SECRET = process.env.APPLE_SHARED_SECRET;
const GOOGLE_SERVICE_ACCOUNT = JSON.parse(process.env.GOOGLE_SERVICE_ACCOUNT);
// iOS — Apple VerifyReceipt
async function verifyAppleReceipt(receiptData) {
const url = isSandbox
? 'https://sandbox.itunes.apple.com/verifyReceipt'
: 'https://buy.itunes.apple.com/verifyReceipt';
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
'receipt-data': receiptData,
'password': APPLE_SHARED_SECRET,
'exclude-old-transactions': true
})
});
const body = await response.json();
if (body.status === 21007) {
// Received production response from sandbox — retry sandbox
return verifyAppleReceiptSandbox(receiptData);
}
if (body.status !== 0) {
throw new Error(`Apple verification failed: ${body.status}`);
}
return {
valid: true,
transactionId: body.receipt.in_app[0]?.transaction_id,
productId: body.receipt.in_app[0]?.product_id,
expiresDate: body.latest_receipt_info?.[0]?.expires_date_ms,
isTrialPeriod: body.latest_receipt_info?.[0]?.is_trial_period === "true"
};
}
// Android — Google Play Developer API
async function verifyGooglePurchase(productId, purchaseToken) {
const auth = await getGoogleAccessToken(GOOGLE_SERVICE_ACCOUNT);
const packageName = 'com.example.app';
const response = await fetch(
`https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${packageName}/purchases/subscriptions/${productId}/tokens/${purchaseToken}`,
{ headers: { Authorization: `Bearer ${auth}` } }
);
const body = await response.json();
if (body.error) throw new Error(`Google verification failed: ${body.error.message}`);
return {
valid: body.paymentState === 1, // 1 = Received
expiryTimeMs: body.expiryTimeMillis,
autoRenewing: body.autoRenewing,
priceCurrencyCode: body.priceCurrencyCode,
priceAmountMicros: body.priceAmountMicros
};
}
// Flutter
import 'package:purchases_flutter/purchases_flutter.dart';
class RevenueCatService {
Future<void> configure() async {
await Purchases.setup(
PurchasesConfiguration(PUBSDK_API_KEY)
..appUserID = user.uniqueId
);
Purchases.addAttributionData(attributionData, AttributionType.appleSearchAds);
}
Future<List<Offering>> fetchOfferings() async {
final offerings = await Purchases.getOfferings();
return offerings.current?.availablePackages ?? [];
}
Future<bool> purchase(Package package) async {
try {
final customerInfo = await Purchases.purchasePackage(package);
return customerInfo.entitlements.active.isNotEmpty;
} on PlatformException catch (e) {
if (e.code == PurchasesErrorCode.purchaseCancelledError) {
return false; // User cancelled
}
rethrow;
}
}
Future<bool> checkEntitlement(String entitlementId) async {
final customerInfo = await Purchases.getCustomerInfo();
return customerInfo.entitlements.active.containsKey(entitlementId);
}
Future<void> restorePurchases() async {
await Purchases.restorePurchases();
}
}
Active (willRenew = true)
|
| grace period (7-30 days)
v
In Grace Period (billing retry active, user still has access)
|
| retry succeeds
v
Active (resolved, willRenew = true)
|
| retry fails after grace period
v
Account Hold (15-30 days, no access, can restore by updating payment)
|
| payment updated
v
Active (resolved, willRenew = true)
|
| hold expired
v
Expired / Lapsed (final, willRenew = false)
// Apple App Store Server Notifications (version 2)
// POST from Apple to your server
app.post('/apple/notifications', async (req, res) => {
const { signedPayload } = req.body;
const payload = JSON.parse(
Buffer.from(signedPayload.split('.')[1], 'base64').toString()
);
switch (payload.notificationType) {
case 'DID_RENEW':
// Subscription renewed — grant access
await grantAccess(payload.subscriptionOriginalTransactionId);
break;
case 'DID_FAIL_TO_RENEW':
// Billing failed — start grace period UI
await warnUser(payload.subscriptionOriginalTransactionId);
break;
case 'DID_CHANGE_RENEWAL_PREF':
// User upgraded/downgraded
await updatePlan(payload.subscriptionOriginalTransactionId, payload.productId);
break;
case 'REFUND':
// User got refund — revoke access immediately
await revokeAccess(payload.subscriptionOriginalTransactionId);
break;
case 'EXPIRED':
// Subscription expired — final state
await markExpired(payload.subscriptionOriginalTransactionId);
break;
}
res.status(200).end();
});
// Google Play Developer Notifications (Real-time developer notifications)
app.post('/google/notifications', async (req, res) => {
const message = Buffer.from(req.body.message.data, 'base64').toString();
const notification = JSON.parse(message);
if (notification.subscriptionNotification) {
const { notificationType, purchaseToken, subscriptionId } = notification.subscriptionNotification;
switch (notificationType) {
case 4: // SUBSCRIPTION_RECOVERED
case 1: // SUBSCRIPTION_RENEWED
await grantAccess(subscriptionId, purchaseToken);
break;
case 2: // SUBSCRIPTION_CANCELED
case 13: // SUBSCRIPTION_REVOKED
await revokeAccess(subscriptionId, purchaseToken);
break;
case 3: // SUBSCRIPTION_RESTARTED
await restartAccess(subscriptionId, purchaseToken);
break;
case 5: // SUBSCRIPTION_ON_HOLD
await warnHold(subscriptionId, purchaseToken);
break;
case 7: // SUBSCRIPTION_PAUSED
case 8: // SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED
case 9: // SUBSCRIPTION_IN_GRACE_PERIOD
await warnPauseOrGrace(subscriptionId, purchaseToken, notificationType);
break;
}
}
res.status(200).end();
});
// StoreKit 2 — iterate current entitlements
func restorePurchases() async -> [String] {
var restoredProductIds: [String] = []
for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result,
transaction.revocationDate == nil else { continue }
restoredProductIds.append(transaction.productID)
}
return restoredProductIds
}
// Play Billing 6+ — query both subs and inapp
fun restorePurchases(callback: (List<String>) -> Unit) {
val restoredIds = mutableListOf<String>()
billingClient.queryPurchasesAsync(BillingClient.ProductType.SUBS) { _, purchases ->
purchases?.forEach { restoredIds.add(it.products.first()) }
}
billingClient.queryPurchasesAsync(BillingClient.ProductType.INAPP) { _, purchases ->
purchases?.forEach { restoredIds.add(it.products.first()) }
callback(restoredIds)
}
}
// iOS — Promotional offer (existing subscriber win-back)
func promotionalOffer(for product: Product, discount: Product.SubscriptionOffer) async throws -> Product.PurchaseResult {
let signedOffer = try await Product.PurchaseOption.promotionalOffer(
offerID: discount.id,
keyID: "KEY_ID",
nonce: UUID(),
signature: "SIGNATURE_FROM_SERVER",
timestamp: Date()
)
return try await product.purchase(options: [signedOffer])
}
// Android — Subscription offer
fun purchaseWithOffer(activity: Activity, product: ProductDetails, offerToken: String) {
val params = BillingFlowParams.newBuilder()
.setProductDetailsList(listOf(product))
.setOfferToken(offerToken)
.build()
billingClient.launchBillingFlow(activity, params)
}
SKReceiptRefreshRequest firstacknowledgePurchase immediatelytransaction.finish() after delivery confirmed.storekit config file in Xcode| Scenario | Setup | Expected Behavior |
|---|---|---|
| Fresh purchase | New sandbox account | Full purchase flow |
| First-time subscription | New account with intro offer | Introductory price applied |
| Renewal | Auto-renewable sub, wait 3-5 min | Renewal notification fires |
| Billing failure | Expired credit card in sandbox account | Grace period triggered |
| Cancel subscription | Cancel in Settings > Sandbox accounts | DID_CHANGE_RENEWAL to cancel |
| Refund | Request refund via sandbox console | REFUND notification |
| Upgrade/downgrade | Purchase second tier in same group | Prorated credit, pending change |
| Restore | Uninstall and reinstall | Restore button returns entitlements |
exclude-old-transactions: true to minimizereferences/in-app-purchase.md — In-App Purchase Integration Guidereferences/play-billing-guide.md — Google Play Billing 5+ Implementationreferences/receipt-validation.md — Server-Side Receipt Validationreferences/storekit-guide.md — App Store StoreKit 2 Guidereferences/subscription-management.md — Subscription Lifecycle Managementreferences/subscription-models.md — Subscription Pricing Modelsreferences/iap-receipt-validation-server.md — Receipt validation serverreferences/iap-subscription-lifecycle.md — Subscription lifecycleAfter IAP integration, hand off to:
mobile/universal/networking — Server-side receipt validation APImobile/universal/security — Secure storage of receipt data, anti-fraudmobile/universal/testing — Sandbox testing, StoreKit config testingmobile/universal/analytics — Purchase funnel events, revenue trackingmobile/universal/storage — Offline entitlement cachingmobile/ios — StoreKit 2, App Store Connect product configmobile/android — Play Billing, Play Console product config