This snapshot establishes the camera-to-result recognition flow and related tests while checking in the project skill/docs assets required for the configured local tooling.
888 lines
25 KiB
Markdown
888 lines
25 KiB
Markdown
---
|
|
name: axiom-in-app-purchases
|
|
description: Use when implementing in-app purchases, StoreKit 2, subscriptions, or transaction handling - testing-first workflow with .storekit configuration, StoreManager architecture, transaction verification, subscription management, and restore purchases for consumables, non-consumables, and auto-renewable subscriptions
|
|
license: MIT
|
|
metadata:
|
|
version: "1.0"
|
|
---
|
|
|
|
# StoreKit 2 In-App Purchase Implementation
|
|
|
|
**Purpose**: Guide robust, testable in-app purchase implementation
|
|
**StoreKit Version**: StoreKit 2
|
|
**iOS Version**: iOS 15+ (iOS 18.4+ for latest features)
|
|
**Xcode**: Xcode 13+ (Xcode 16+ recommended)
|
|
**Context**: WWDC 2025-241, 2025-249, 2023-10013, 2021-10114
|
|
|
|
## When to Use This Skill
|
|
|
|
✅ **Use this skill when**:
|
|
- Implementing any in-app purchase functionality (new or existing)
|
|
- Adding consumable products (coins, hints, boosts)
|
|
- Adding non-consumable products (premium features, level packs)
|
|
- Adding auto-renewable subscriptions (monthly/annual plans)
|
|
- Debugging purchase failures, missing transactions, or restore issues
|
|
- Setting up StoreKit testing configuration
|
|
- Implementing subscription status tracking
|
|
- Adding promotional offers or introductory offers
|
|
- Server-side receipt validation
|
|
- Family Sharing support
|
|
|
|
❌ **Do NOT use this skill for**:
|
|
- StoreKit 1 (legacy API) - this skill focuses on StoreKit 2
|
|
- App Store Connect product configuration (separate documentation)
|
|
- Pricing strategy or business model decisions
|
|
|
|
---
|
|
|
|
## ⚠️ Already Wrote Code Before Creating .storekit Config?
|
|
|
|
If you wrote purchase code before creating `.storekit` configuration, you have three options:
|
|
|
|
### Option A: Delete and Start Over (Strongly Recommended)
|
|
|
|
Delete all IAP code and follow the testing-first workflow below. This reinforces correct habits and ensures you experience the full benefit of .storekit-first development.
|
|
|
|
**Why this is best**:
|
|
- Validates that you understand the workflow
|
|
- Catches product ID issues you might have missed
|
|
- Builds muscle memory for future IAP implementations
|
|
- Takes only 15-30 minutes for experienced developers
|
|
|
|
### Option B: Create .storekit Config Now (Acceptable with Caution)
|
|
|
|
Create the `.storekit` file now with your existing product IDs. Test everything works locally. Document in your PR that you tested in sandbox first.
|
|
|
|
**Trade-offs**:
|
|
- ✅ Keeps working code
|
|
- ✅ Adds local testing capability
|
|
- ❌ Misses product ID validation benefit
|
|
- ❌ Reinforces testing-after pattern
|
|
- ❌ Requires extra vigilance in code review
|
|
|
|
**If choosing this path**: Create .storekit immediately, verify locally, and commit a note explaining the approach.
|
|
|
|
### Option C: Skip .storekit Entirely (Not Recommended)
|
|
|
|
Commit without `.storekit` configuration, test only in sandbox.
|
|
|
|
**Why this is problematic**:
|
|
- Teammates can't test purchases locally
|
|
- No validation of product IDs before runtime
|
|
- Harder iteration (requires App Store Connect)
|
|
- Missing documentation of product structure
|
|
|
|
**Bottom line**: Choose Option A if possible, Option B if pragmatic, never Option C.
|
|
|
|
---
|
|
|
|
## Core Philosophy: Testing-First Workflow
|
|
|
|
> **Best Practice**: Create and test StoreKit configuration BEFORE writing production purchase code.
|
|
|
|
### Why .storekit-First Matters
|
|
|
|
The recommended workflow is to create `.storekit` configuration before writing any purchase code. This isn't arbitrary - it provides concrete benefits:
|
|
|
|
**Immediate product ID validation**:
|
|
- Typos caught in Xcode, not at runtime
|
|
- Product configuration visible in project
|
|
- No App Store Connect dependency for testing
|
|
|
|
**Faster iteration**:
|
|
- Test purchases in simulator instantly
|
|
- No network requests during development
|
|
- Accelerated subscription renewal for testing
|
|
|
|
**Team benefits**:
|
|
- Anyone can test purchase flows locally
|
|
- Product catalog documented in code
|
|
- Code review includes purchase testing
|
|
|
|
**Common objections addressed**:
|
|
|
|
❓ **"I already tested in sandbox"** - Sandbox testing is valuable but comes later. Local testing with .storekit is faster and enables true TDD.
|
|
|
|
❓ **"My code works"** - Working code is great! Adding .storekit makes it easier for teammates to verify and maintain.
|
|
|
|
❓ **"I've done this before"** - Experience is valuable. The .storekit-first workflow makes experienced developers even more productive.
|
|
|
|
❓ **"Time pressure"** - Creating .storekit takes 10-15 minutes. The time saved in iteration pays back immediately.
|
|
|
|
### The Recommended Workflow
|
|
|
|
```
|
|
StoreKit Config → Local Testing → Production Code → Unit Tests → Sandbox Testing
|
|
↓ ↓ ↓ ↓ ↓
|
|
.storekit Test purchases StoreManager Mock store Integration test
|
|
```
|
|
|
|
**Why this order helps**:
|
|
1. **StoreKit Config First**: Defines products without App Store Connect dependency
|
|
2. **Local Testing**: Validates product IDs and purchase flows immediately
|
|
3. **Production Code**: Implements against validated product configuration
|
|
4. **Unit Tests**: Verifies business logic with mocked store responses
|
|
5. **Sandbox Testing**: Final validation in App Store environment
|
|
|
|
**Benefits of following this workflow**:
|
|
- Product IDs validated before writing code
|
|
- Faster development iteration
|
|
- Easier team collaboration
|
|
- Better test coverage
|
|
|
|
---
|
|
|
|
## Mandatory Checklist
|
|
|
|
Before marking IAP implementation complete, **ALL** items must be verified:
|
|
|
|
### Phase 1: Testing Foundation
|
|
- [ ] Created `.storekit` configuration file with all products
|
|
- [ ] Verified each product type renders correctly in StoreKit preview
|
|
- [ ] Tested successful purchase flow for each product in Xcode
|
|
- [ ] Tested purchase failure scenarios (insufficient funds, cancelled)
|
|
- [ ] Tested restore purchases flow
|
|
- [ ] For subscriptions: tested renewal, expiration, and upgrade/downgrade
|
|
|
|
### Phase 2: Architecture
|
|
- [ ] Centralized StoreManager class exists (single source of truth)
|
|
- [ ] StoreManager is ObservableObject (SwiftUI) or uses NotificationCenter
|
|
- [ ] Transaction observer listens for updates via `Transaction.updates`
|
|
- [ ] All transaction verification uses `VerificationResult`
|
|
- [ ] All transactions call `.finish()` after entitlement granted
|
|
- [ ] Product loading happens at app launch or before displaying store
|
|
|
|
### Phase 3: Purchase Flow
|
|
- [ ] Purchase uses new `purchase(confirmIn:options:)` with UI context (iOS 18.2+)
|
|
- [ ] Purchase handles all `PurchaseResult` cases (success, userCancelled, pending)
|
|
- [ ] Purchase verifies transaction signature before granting entitlement
|
|
- [ ] Purchase stores transaction receipt/identifier for support
|
|
- [ ] appAccountToken set for all purchases (if using server backend)
|
|
|
|
### Phase 4: Subscription Management (if applicable)
|
|
- [ ] Subscription status tracked via `Product.SubscriptionInfo.Status`
|
|
- [ ] Current entitlements checked via `Transaction.currentEntitlements(for:)`
|
|
- [ ] Renewal info accessed for expiration, renewal date, offer status
|
|
- [ ] Subscription views use ProductView or SubscriptionStoreView
|
|
- [ ] Win-back offers implemented for expired subscriptions
|
|
- [ ] Grace period and billing retry states handled
|
|
|
|
### Phase 5: Restore & Sync
|
|
- [ ] Restore purchases implemented (required by App Store Review)
|
|
- [ ] Restore uses `Transaction.currentEntitlements` or `Transaction.all`
|
|
- [ ] Family Sharing transactions identified (if supported)
|
|
- [ ] Server sync implemented (if using backend)
|
|
- [ ] Cross-device entitlement sync tested
|
|
|
|
### Phase 6: Error Handling
|
|
- [ ] Network errors handled gracefully (retries, user messaging)
|
|
- [ ] Invalid product IDs detected and logged
|
|
- [ ] Purchase failures show user-friendly error messages
|
|
- [ ] Transaction verification failures logged and reported
|
|
- [ ] Refund notifications handled (via App Store Server Notifications)
|
|
|
|
### Phase 7: Testing & Validation
|
|
- [ ] Unit tests verify purchase logic with mocked Product/Transaction
|
|
- [ ] Unit tests verify subscription status determination
|
|
- [ ] Integration tests with StoreKit configuration pass
|
|
- [ ] Sandbox testing with real Apple ID completed
|
|
- [ ] TestFlight testing completed before production release
|
|
|
|
---
|
|
|
|
## Step 1: Create StoreKit Configuration (FIRST!)
|
|
|
|
**DO THIS BEFORE WRITING ANY PURCHASE CODE.**
|
|
|
|
### Create Configuration File
|
|
|
|
1. **Xcode → File → New → File → StoreKit Configuration File**
|
|
2. **Save as**: `Products.storekit` (or your app name)
|
|
3. **Add to target**: ✅ (include in app bundle for testing)
|
|
|
|
### Add Products
|
|
|
|
Click "+" and add each product type:
|
|
|
|
#### Consumable
|
|
```
|
|
Product ID: com.yourapp.coins_100
|
|
Reference Name: 100 Coins
|
|
Price: $0.99
|
|
```
|
|
|
|
#### Non-Consumable
|
|
```
|
|
Product ID: com.yourapp.premium
|
|
Reference Name: Premium Upgrade
|
|
Price: $4.99
|
|
```
|
|
|
|
#### Auto-Renewable Subscription
|
|
```
|
|
Product ID: com.yourapp.pro_monthly
|
|
Reference Name: Pro Monthly
|
|
Price: $9.99/month
|
|
Subscription Group ID: pro_tier
|
|
```
|
|
|
|
### Test Immediately
|
|
|
|
1. **Run app in simulator**
|
|
2. **Scheme → Edit Scheme → Run → Options**
|
|
3. **StoreKit Configuration**: Select `Products.storekit`
|
|
4. **Verify**: Products load, purchases complete, transactions appear
|
|
|
|
---
|
|
|
|
## Step 2: Implement StoreManager Architecture
|
|
|
|
### Required Pattern: Centralized StoreManager
|
|
|
|
**All purchase logic must go through a single StoreManager.** No scattered `Product.purchase()` calls throughout app.
|
|
|
|
```swift
|
|
import StoreKit
|
|
|
|
@MainActor
|
|
final class StoreManager: ObservableObject {
|
|
// Published state for UI
|
|
@Published private(set) var products: [Product] = []
|
|
@Published private(set) var purchasedProductIDs: Set<String> = []
|
|
|
|
// Product IDs from StoreKit configuration
|
|
private let productIDs = [
|
|
"com.yourapp.coins_100",
|
|
"com.yourapp.premium",
|
|
"com.yourapp.pro_monthly"
|
|
]
|
|
|
|
private var transactionListener: Task<Void, Never>?
|
|
|
|
init() {
|
|
// Start transaction listener immediately
|
|
transactionListener = listenForTransactions()
|
|
|
|
Task {
|
|
await loadProducts()
|
|
await updatePurchasedProducts()
|
|
}
|
|
}
|
|
|
|
deinit {
|
|
transactionListener?.cancel()
|
|
}
|
|
}
|
|
```
|
|
|
|
**Why @MainActor**: Published properties must update on main thread for UI binding.
|
|
|
|
### Load Products (At Launch)
|
|
|
|
```swift
|
|
extension StoreManager {
|
|
func loadProducts() async {
|
|
do {
|
|
// Load products from App Store
|
|
let loadedProducts = try await Product.products(for: productIDs)
|
|
|
|
// Update published property on main thread
|
|
self.products = loadedProducts
|
|
|
|
} catch {
|
|
print("Failed to load products: \(error)")
|
|
// Show error to user
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Call from**: `App.init()` or first view's `.task` modifier
|
|
|
|
### Listen for Transactions (REQUIRED)
|
|
|
|
```swift
|
|
extension StoreManager {
|
|
func listenForTransactions() -> Task<Void, Never> {
|
|
Task.detached { [weak self] in
|
|
// Listen for ALL transaction updates
|
|
for await verificationResult in Transaction.updates {
|
|
await self?.handleTransaction(verificationResult)
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func handleTransaction(_ result: VerificationResult<Transaction>) async {
|
|
// Verify transaction signature
|
|
guard let transaction = try? result.payloadValue else {
|
|
print("Transaction verification failed")
|
|
return
|
|
}
|
|
|
|
// Grant entitlement to user
|
|
await grantEntitlement(for: transaction)
|
|
|
|
// CRITICAL: Always finish transaction
|
|
await transaction.finish()
|
|
|
|
// Update purchased products
|
|
await updatePurchasedProducts()
|
|
}
|
|
}
|
|
```
|
|
|
|
**Why detached**: Transaction listener runs independently of view lifecycle
|
|
|
|
---
|
|
|
|
## Step 3: Implement Purchase Flow
|
|
|
|
### Purchase with UI Context (iOS 18.2+)
|
|
|
|
```swift
|
|
extension StoreManager {
|
|
func purchase(_ product: Product, confirmIn scene: UIWindowScene) async throws -> Bool {
|
|
// Perform purchase with UI context for payment sheet
|
|
let result = try await product.purchase(confirmIn: scene)
|
|
|
|
switch result {
|
|
case .success(let verificationResult):
|
|
// Verify the transaction
|
|
guard let transaction = try? verificationResult.payloadValue else {
|
|
print("Transaction verification failed")
|
|
return false
|
|
}
|
|
|
|
// Grant entitlement
|
|
await grantEntitlement(for: transaction)
|
|
|
|
// CRITICAL: Finish transaction
|
|
await transaction.finish()
|
|
|
|
// Update state
|
|
await updatePurchasedProducts()
|
|
|
|
return true
|
|
|
|
case .userCancelled:
|
|
// User tapped "Cancel" in payment sheet
|
|
return false
|
|
|
|
case .pending:
|
|
// Purchase requires action (Ask to Buy, payment issue)
|
|
// Will be delivered via Transaction.updates when approved
|
|
return false
|
|
|
|
@unknown default:
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### SwiftUI Purchase (Using Environment)
|
|
|
|
```swift
|
|
struct ProductRow: View {
|
|
let product: Product
|
|
@Environment(\.purchase) private var purchase
|
|
|
|
var body: some View {
|
|
Button("Buy \(product.displayPrice)") {
|
|
Task {
|
|
do {
|
|
let result = try await purchase(product)
|
|
// Handle result
|
|
} catch {
|
|
print("Purchase failed: \(error)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Set appAccountToken (If Using Backend)
|
|
|
|
```swift
|
|
func purchase(
|
|
_ product: Product,
|
|
confirmIn scene: UIWindowScene,
|
|
accountToken: UUID
|
|
) async throws -> Bool {
|
|
// Purchase with appAccountToken for server-side association
|
|
let result = try await product.purchase(
|
|
confirmIn: scene,
|
|
options: [
|
|
.appAccountToken(accountToken)
|
|
]
|
|
)
|
|
|
|
// ... handle result
|
|
}
|
|
```
|
|
|
|
**When to use**: When your backend needs to associate purchases with user accounts
|
|
|
|
---
|
|
|
|
## Step 4: Verify Transactions (MANDATORY)
|
|
|
|
### Always Use VerificationResult
|
|
|
|
```swift
|
|
func handleTransaction(_ result: VerificationResult<Transaction>) async {
|
|
switch result {
|
|
case .verified(let transaction):
|
|
// ✅ Transaction signed by App Store
|
|
await grantEntitlement(for: transaction)
|
|
await transaction.finish()
|
|
|
|
case .unverified(let transaction, let error):
|
|
// ❌ Transaction signature invalid
|
|
print("Unverified transaction: \(error)")
|
|
// DO NOT grant entitlement
|
|
// DO finish transaction to clear from queue
|
|
await transaction.finish()
|
|
}
|
|
}
|
|
```
|
|
|
|
**Why verify**: Prevents granting entitlements for:
|
|
- Fraudulent receipts
|
|
- Jailbroken device receipts
|
|
- Man-in-the-middle attacks
|
|
|
|
### Check Transaction Fields
|
|
|
|
```swift
|
|
func grantEntitlement(for transaction: Transaction) async {
|
|
// Check transaction hasn't been revoked
|
|
guard transaction.revocationDate == nil else {
|
|
print("Transaction was refunded")
|
|
await revokeEntitlement(for: transaction.productID)
|
|
return
|
|
}
|
|
|
|
// Grant based on product type
|
|
switch transaction.productType {
|
|
case .consumable:
|
|
await addConsumable(productID: transaction.productID)
|
|
|
|
case .nonConsumable:
|
|
await unlockFeature(productID: transaction.productID)
|
|
|
|
case .autoRenewable:
|
|
await activateSubscription(productID: transaction.productID)
|
|
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Step 5: Track Current Entitlements
|
|
|
|
### Check What User Owns
|
|
|
|
```swift
|
|
extension StoreManager {
|
|
func updatePurchasedProducts() async {
|
|
var purchased: Set<String> = []
|
|
|
|
// Iterate through all current entitlements
|
|
for await result in Transaction.currentEntitlements {
|
|
guard let transaction = try? result.payloadValue else {
|
|
continue
|
|
}
|
|
|
|
// Only include active entitlements (not revoked)
|
|
if transaction.revocationDate == nil {
|
|
purchased.insert(transaction.productID)
|
|
}
|
|
}
|
|
|
|
self.purchasedProductIDs = purchased
|
|
}
|
|
}
|
|
```
|
|
|
|
### Check Specific Product
|
|
|
|
```swift
|
|
func isEntitled(to productID: String) async -> Bool {
|
|
// Check current entitlements for specific product
|
|
for await result in Transaction.currentEntitlements(for: productID) {
|
|
if let transaction = try? result.payloadValue,
|
|
transaction.revocationDate == nil {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Step 6: Implement Subscription Management
|
|
|
|
### Track Subscription Status
|
|
|
|
```swift
|
|
extension StoreManager {
|
|
func checkSubscriptionStatus(for groupID: String) async -> Product.SubscriptionInfo.Status? {
|
|
// Get subscription statuses for group
|
|
guard let result = try? await Product.SubscriptionInfo.status(for: groupID),
|
|
let status = result.first else {
|
|
return nil
|
|
}
|
|
|
|
return status.state
|
|
}
|
|
}
|
|
```
|
|
|
|
### Handle Subscription States
|
|
|
|
```swift
|
|
func updateSubscriptionUI(for status: Product.SubscriptionInfo.Status) {
|
|
switch status.state {
|
|
case .subscribed:
|
|
// User has active subscription
|
|
showSubscribedContent()
|
|
|
|
case .expired:
|
|
// Subscription expired - show win-back offer
|
|
showResubscribeOffer()
|
|
|
|
case .inGracePeriod:
|
|
// Billing issue - show payment update prompt
|
|
showUpdatePaymentPrompt()
|
|
|
|
case .inBillingRetryPeriod:
|
|
// Apple retrying payment - maintain access
|
|
showBillingRetryMessage()
|
|
|
|
case .revoked:
|
|
// Family Sharing access removed
|
|
removeAccess()
|
|
|
|
@unknown default:
|
|
break
|
|
}
|
|
}
|
|
```
|
|
|
|
### Use StoreKit Views (iOS 17+)
|
|
|
|
```swift
|
|
struct SubscriptionView: View {
|
|
var body: some View {
|
|
SubscriptionStoreView(groupID: "pro_tier") {
|
|
// Marketing content
|
|
VStack {
|
|
Image("premium-icon")
|
|
Text("Unlock all features")
|
|
}
|
|
}
|
|
.subscriptionStoreControlStyle(.prominentPicker)
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Step 7: Implement Restore Purchases (REQUIRED)
|
|
|
|
### Restore Flow
|
|
|
|
```swift
|
|
extension StoreManager {
|
|
func restorePurchases() async {
|
|
// Sync all transactions from App Store
|
|
try? await AppStore.sync()
|
|
|
|
// Update current entitlements
|
|
await updatePurchasedProducts()
|
|
}
|
|
}
|
|
```
|
|
|
|
### UI Button
|
|
|
|
```swift
|
|
struct SettingsView: View {
|
|
@StateObject private var store = StoreManager()
|
|
|
|
var body: some View {
|
|
Button("Restore Purchases") {
|
|
Task {
|
|
await store.restorePurchases()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**App Store Requirement**: Apps with IAP must provide restore functionality for non-consumables and subscriptions.
|
|
|
|
---
|
|
|
|
## Step 8: Handle Refunds
|
|
|
|
### Listen for Refund Notifications
|
|
|
|
```swift
|
|
extension StoreManager {
|
|
func listenForTransactions() -> Task<Void, Never> {
|
|
Task.detached { [weak self] in
|
|
for await verificationResult in Transaction.updates {
|
|
await self?.handleTransaction(verificationResult)
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func handleTransaction(_ result: VerificationResult<Transaction>) async {
|
|
guard let transaction = try? result.payloadValue else {
|
|
return
|
|
}
|
|
|
|
// Check if transaction was refunded
|
|
if let revocationDate = transaction.revocationDate {
|
|
print("Transaction refunded on \(revocationDate)")
|
|
await revokeEntitlement(for: transaction.productID)
|
|
} else {
|
|
await grantEntitlement(for: transaction)
|
|
}
|
|
|
|
await transaction.finish()
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Step 9: Unit Testing
|
|
|
|
### Mock Store Responses
|
|
|
|
```swift
|
|
protocol StoreProtocol {
|
|
func products(for ids: [String]) async throws -> [Product]
|
|
func purchase(_ product: Product) async throws -> PurchaseResult
|
|
}
|
|
|
|
// Production
|
|
final class StoreManager: StoreProtocol {
|
|
func products(for ids: [String]) async throws -> [Product] {
|
|
try await Product.products(for: ids)
|
|
}
|
|
}
|
|
|
|
// Testing
|
|
final class MockStore: StoreProtocol {
|
|
var mockProducts: [Product] = []
|
|
var mockPurchaseResult: PurchaseResult?
|
|
|
|
func products(for ids: [String]) async throws -> [Product] {
|
|
mockProducts
|
|
}
|
|
|
|
func purchase(_ product: Product) async throws -> PurchaseResult {
|
|
mockPurchaseResult ?? .userCancelled
|
|
}
|
|
}
|
|
```
|
|
|
|
### Test Purchase Logic
|
|
|
|
```swift
|
|
@Test func testSuccessfulPurchase() async {
|
|
let mockStore = MockStore()
|
|
let manager = StoreManager(store: mockStore)
|
|
|
|
// Given: Mock successful purchase
|
|
mockStore.mockPurchaseResult = .success(.verified(mockTransaction))
|
|
|
|
// When: Purchase product
|
|
let result = await manager.purchase(mockProduct)
|
|
|
|
// Then: Entitlement granted
|
|
#expect(result == true)
|
|
#expect(manager.purchasedProductIDs.contains("com.app.premium"))
|
|
}
|
|
|
|
@Test func testCancelledPurchase() async {
|
|
let mockStore = MockStore()
|
|
let manager = StoreManager(store: mockStore)
|
|
|
|
// Given: User cancels
|
|
mockStore.mockPurchaseResult = .userCancelled
|
|
|
|
// When: Purchase product
|
|
let result = await manager.purchase(mockProduct)
|
|
|
|
// Then: No entitlement granted
|
|
#expect(result == false)
|
|
#expect(manager.purchasedProductIDs.isEmpty)
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Common Anti-Patterns (NEVER DO THIS)
|
|
|
|
### ❌ No StoreKit Configuration
|
|
|
|
```swift
|
|
// ❌ WRONG: Writing purchase code without .storekit file
|
|
let products = try await Product.products(for: productIDs)
|
|
// Can't test this without App Store Connect setup!
|
|
```
|
|
|
|
✅ **Correct**: Create `.storekit` file FIRST, test in Xcode, THEN implement.
|
|
|
|
### ❌ Code Before .storekit Config
|
|
|
|
```swift
|
|
// ❌ Less ideal: Write code, test in sandbox, add .storekit later
|
|
let products = try await Product.products(for: productIDs)
|
|
let result = try await product.purchase(confirmIn: scene)
|
|
// "I tested this in sandbox, it works! I'll add .storekit config later."
|
|
```
|
|
|
|
✅ **Recommended**: Create `.storekit` config first, then write code.
|
|
|
|
**If you're in this situation**: See "Already Wrote Code Before Creating .storekit Config?" section above for your options (A, B, or C).
|
|
|
|
**Why .storekit-first is better**:
|
|
- Product ID typos caught in Xcode, not at runtime
|
|
- Faster iteration without network requests
|
|
- Teammates can test locally
|
|
- Documents product structure in code
|
|
|
|
**Sandbox testing is valuable** - it validates against real App Store infrastructure. But starting with .storekit makes sandbox testing easier because you've already validated product IDs locally.
|
|
|
|
### ❌ Scattered Purchase Calls
|
|
|
|
```swift
|
|
// ❌ WRONG: Purchase calls scattered throughout app
|
|
Button("Buy") {
|
|
try await product.purchase() // In view 1
|
|
}
|
|
|
|
Button("Subscribe") {
|
|
try await subscriptionProduct.purchase() // In view 2
|
|
}
|
|
```
|
|
|
|
✅ **Correct**: All purchases through centralized StoreManager.
|
|
|
|
### ❌ Forgetting to Finish Transactions
|
|
|
|
```swift
|
|
// ❌ WRONG: Never calling finish()
|
|
func handleTransaction(_ transaction: Transaction) {
|
|
grantEntitlement(for: transaction)
|
|
// Missing: await transaction.finish()
|
|
}
|
|
```
|
|
|
|
✅ **Correct**: ALWAYS call `transaction.finish()` after granting entitlement.
|
|
|
|
### ❌ Not Verifying Transactions
|
|
|
|
```swift
|
|
// ❌ WRONG: Using unverified transaction
|
|
for await transaction in Transaction.all {
|
|
grantEntitlement(for: transaction) // Unsafe!
|
|
}
|
|
```
|
|
|
|
✅ **Correct**: Always check `VerificationResult` before granting.
|
|
|
|
### ❌ Ignoring Transaction Listener
|
|
|
|
```swift
|
|
// ❌ WRONG: Only handling purchases in purchase() method
|
|
func purchase() {
|
|
let result = try await product.purchase()
|
|
// What about pending purchases, family sharing, restore?
|
|
}
|
|
```
|
|
|
|
✅ **Correct**: Listen to `Transaction.updates` for ALL transaction sources.
|
|
|
|
### ❌ Not Implementing Restore
|
|
|
|
```swift
|
|
// ❌ WRONG: No restore button
|
|
// App Store will REJECT your app!
|
|
```
|
|
|
|
✅ **Correct**: Provide visible "Restore Purchases" button in settings.
|
|
|
|
---
|
|
|
|
## Validation
|
|
|
|
Before marking IAP implementation complete, verify:
|
|
|
|
### Code Inspection
|
|
|
|
Run these searches to verify compliance:
|
|
|
|
```bash
|
|
# Check StoreKit configuration exists
|
|
find . -name "*.storekit"
|
|
|
|
# Check transaction.finish() is called
|
|
rg "transaction\.finish\(\)" --type swift
|
|
|
|
# Check VerificationResult usage
|
|
rg "VerificationResult" --type swift
|
|
|
|
# Check Transaction.updates listener
|
|
rg "Transaction\.updates" --type swift
|
|
|
|
# Check restore implementation
|
|
rg "AppStore\.sync|Transaction\.all" --type swift
|
|
```
|
|
|
|
### Functional Testing
|
|
|
|
- [ ] Can purchase each product type in StoreKit configuration
|
|
- [ ] Can cancel purchase and state remains consistent
|
|
- [ ] Can restore purchases and regain access
|
|
- [ ] Subscription renewal/expiration works as expected
|
|
- [ ] Refunded transactions revoke access
|
|
- [ ] Family Sharing transactions identified (if supported)
|
|
|
|
### Sandbox Testing
|
|
|
|
- [ ] Real Apple ID sandbox purchases complete
|
|
- [ ] TestFlight beta testers confirm purchase flows work
|
|
- [ ] Server-side validation works (if using backend)
|
|
|
|
### App Store Connect Submission (see **app-store-submission** for full checklist)
|
|
|
|
- [ ] **Review screenshot uploaded** for each IAP product (shows purchase UI — review-only, not on App Store)
|
|
- [ ] **IAP products attached to this version** (first submission: App Version → In-App Purchases section → Select → checkbox each product)
|
|
- [ ] **Terms of Use + Privacy Policy links on purchase screen** (required by DPLA Schedule 2; `SubscriptionStoreView` handles this automatically)
|
|
- [ ] Subscription terms explicit: price, period, auto-renewal, cancellation
|
|
|
|
---
|
|
|
|
## Resources
|
|
|
|
**WWDC**: 2025-241, 2025-249, 2023-10013, 2021-10114
|
|
|
|
**Docs**: /storekit, /appstoreserverapi
|
|
|
|
**Skills**: axiom-storekit-ref
|