axiom-in-app-purchases by charleswiltgen/axiom
npx skills add https://github.com/charleswiltgen/axiom --skill axiom-in-app-purchases目的 : 指导稳健、可测试的应用内购买实现 StoreKit 版本 : StoreKit 2 iOS 版本 : iOS 15+ (最新功能需 iOS 18.4+) Xcode : Xcode 13+ (推荐 Xcode 16+) 背景 : WWDC 2025-241, 2025-249, 2023-10013, 2021-10114
✅ 在以下情况使用此技能 :
❌ 请勿将此技能用于 :
如果您在创建 .storekit 配置之前已经编写了购买代码,您有三个选择:
删除所有 IAP 代码,并遵循下面的测试优先工作流程。这可以强化正确的习惯,并确保您体验到 .storekit 优先开发的全部好处。
为什么这是最佳选择 :
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
使用您现有的产品 ID 立即创建 .storekit 文件。在本地测试所有功能是否正常。在您的 PR 中注明您首先在沙盒环境中进行了测试。
权衡 :
如果选择此路径 : 立即创建 .storekit,在本地验证,并提交说明解释此方法。
提交时不包含 .storekit 配置,仅在沙盒环境中测试。
为什么这有问题 :
底线 : 如果可能,选择选项 A;如果务实,选择选项 B;永远不要选择选项 C。
最佳实践 : 在编写生产购买代码之前,创建并测试 StoreKit 配置。
推荐的工作流程是在编写任何购买代码之前创建 .storekit 配置。这不是随意的 - 它提供了具体的好处:
立即验证产品 ID :
更快的迭代 :
团队利益 :
常见异议解答 :
❓ "我已经在沙盒中测试过了" - 沙盒测试很有价值,但稍后进行。使用 .storekit 进行本地测试更快,并支持真正的 TDD。
❓ "我的代码可以工作" - 工作代码很棒!添加 .storekit 使团队成员更容易验证和维护。
❓ "我以前做过这个" - 经验很有价值。.storekit 优先的工作流程使有经验的开发人员更加高效。
❓ "时间压力" - 创建 .storekit 需要 10-15 分钟。迭代节省的时间会立即得到回报。
StoreKit Config → Local Testing → Production Code → Unit Tests → Sandbox Testing
↓ ↓ ↓ ↓ ↓
.storekit Test purchases StoreManager Mock store Integration test
为什么这个顺序有帮助 :
遵循此工作流程的好处 :
在标记 IAP 实现完成之前,所有项目必须经过验证:
.storekit 配置文件Transaction.updates 监听更新VerificationResult.finish()purchase(confirmIn:options:) 并带有 UI 上下文 (iOS 18.2+)PurchaseResult 情况(成功、用户取消、待处理)Product.SubscriptionInfo.Status 跟踪订阅状态Transaction.currentEntitlements(for:) 检查当前权益Transaction.currentEntitlements 或 Transaction.all在编写任何购买代码之前执行此操作。
Products.storekit(或您的应用名称)点击 "+" 并添加每种产品类型:
Product ID: com.yourapp.coins_100
Reference Name: 100 Coins
Price: $0.99
Product ID: com.yourapp.premium
Reference Name: Premium Upgrade
Price: $4.99
Product ID: com.yourapp.pro_monthly
Reference Name: Pro Monthly
Price: $9.99/month
Subscription Group ID: pro_tier
Products.storekit所有购买逻辑必须通过单一的 StoreManager。 不要在应用中分散使用 Product.purchase() 调用。
import StoreKit
@MainActor
final class StoreManager: ObservableObject {
// 用于 UI 的发布状态
@Published private(set) var products: [Product] = []
@Published private(set) var purchasedProductIDs: Set<String> = []
// 来自 StoreKit 配置的产品 ID
private let productIDs = [
"com.yourapp.coins_100",
"com.yourapp.premium",
"com.yourapp.pro_monthly"
]
private var transactionListener: Task<Void, Never>?
init() {
// 立即启动交易监听器
transactionListener = listenForTransactions()
Task {
await loadProducts()
await updatePurchasedProducts()
}
}
deinit {
transactionListener?.cancel()
}
}
为什么使用 @MainActor : 发布属性必须在主线程上更新以进行 UI 绑定。
extension StoreManager {
func loadProducts() async {
do {
// 从 App Store 加载产品
let loadedProducts = try await Product.products(for: productIDs)
// 在主线程上更新发布属性
self.products = loadedProducts
} catch {
print("Failed to load products: \(error)")
// 向用户显示错误
}
}
}
调用位置 : App.init() 或第一个视图的 .task 修饰符
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 {
print("Transaction verification failed")
return
}
// 向用户授予权益
await grantEntitlement(for: transaction)
// 关键:始终完成交易
await transaction.finish()
// 更新已购买产品
await updatePurchasedProducts()
}
}
为什么使用 detached : 交易监听器独立于视图生命周期运行
extension StoreManager {
func purchase(_ product: Product, confirmIn scene: UIWindowScene) async throws -> Bool {
// 使用 UI 上下文执行购买以显示支付表单
let result = try await product.purchase(confirmIn: scene)
switch result {
case .success(let verificationResult):
// 验证交易
guard let transaction = try? verificationResult.payloadValue else {
print("Transaction verification failed")
return false
}
// 授予权益
await grantEntitlement(for: transaction)
// 关键:完成交易
await transaction.finish()
// 更新状态
await updatePurchasedProducts()
return true
case .userCancelled:
// 用户在支付表单中点击了"取消"
return false
case .pending:
// 购买需要操作(请求购买、支付问题)
// 批准后将通过 Transaction.updates 传递
return false
@unknown default:
return false
}
}
}
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)
// 处理结果
} catch {
print("Purchase failed: \(error)")
}
}
}
}
}
func purchase(
_ product: Product,
confirmIn scene: UIWindowScene,
accountToken: UUID
) async throws -> Bool {
// 使用 appAccountToken 进行购买以用于服务器端关联
let result = try await product.purchase(
confirmIn: scene,
options: [
.appAccountToken(accountToken)
]
)
// ... 处理结果
}
何时使用 : 当您的后端需要将购买与用户账户关联时
func handleTransaction(_ result: VerificationResult<Transaction>) async {
switch result {
case .verified(let transaction):
// ✅ 交易由 App Store 签名
await grantEntitlement(for: transaction)
await transaction.finish()
case .unverified(let transaction, let error):
// ❌ 交易签名无效
print("Unverified transaction: \(error)")
// 不要授予权益
// 完成交易以从队列中清除
await transaction.finish()
}
}
为什么需要验证 : 防止为以下情况授予权益:
func grantEntitlement(for transaction: Transaction) async {
// 检查交易是否未被撤销
guard transaction.revocationDate == nil else {
print("Transaction was refunded")
await revokeEntitlement(for: transaction.productID)
return
}
// 根据产品类型授予权益
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
}
}
extension StoreManager {
func updatePurchasedProducts() async {
var purchased: Set<String> = []
// 遍历所有当前权益
for await result in Transaction.currentEntitlements {
guard let transaction = try? result.payloadValue else {
continue
}
// 仅包含活跃权益(未撤销)
if transaction.revocationDate == nil {
purchased.insert(transaction.productID)
}
}
self.purchasedProductIDs = purchased
}
}
func isEntitled(to productID: String) async -> Bool {
// 检查特定产品的当前权益
for await result in Transaction.currentEntitlements(for: productID) {
if let transaction = try? result.payloadValue,
transaction.revocationDate == nil {
return true
}
}
return false
}
extension StoreManager {
func checkSubscriptionStatus(for groupID: String) async -> Product.SubscriptionInfo.Status? {
// 获取组的订阅状态
guard let result = try? await Product.SubscriptionInfo.status(for: groupID),
let status = result.first else {
return nil
}
return status.state
}
}
func updateSubscriptionUI(for status: Product.SubscriptionInfo.Status) {
switch status.state {
case .subscribed:
// 用户拥有活跃订阅
showSubscribedContent()
case .expired:
// 订阅已过期 - 显示赢回优惠
showResubscribeOffer()
case .inGracePeriod:
// 账单问题 - 显示更新支付提示
showUpdatePaymentPrompt()
case .inBillingRetryPeriod:
// Apple 正在重试支付 - 维持访问权限
showBillingRetryMessage()
case .revoked:
// 家庭共享访问权限被移除
removeAccess()
@unknown default:
break
}
}
struct SubscriptionView: View {
var body: some View {
SubscriptionStoreView(groupID: "pro_tier") {
// 营销内容
VStack {
Image("premium-icon")
Text("Unlock all features")
}
}
.subscriptionStoreControlStyle(.prominentPicker)
}
}
extension StoreManager {
func restorePurchases() async {
// 从 App Store 同步所有交易
try? await AppStore.sync()
// 更新当前权益
await updatePurchasedProducts()
}
}
struct SettingsView: View {
@StateObject private var store = StoreManager()
var body: some View {
Button("Restore Purchases") {
Task {
await store.restorePurchases()
}
}
}
}
App Store 要求 : 包含 IAP 的应用必须为非消耗型产品和订阅提供恢复功能。
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
}
// 检查交易是否被退款
if let revocationDate = transaction.revocationDate {
print("Transaction refunded on \(revocationDate)")
await revokeEntitlement(for: transaction.productID)
} else {
await grantEntitlement(for: transaction)
}
await transaction.finish()
}
}
protocol StoreProtocol {
func products(for ids: [String]) async throws -> [Product]
func purchase(_ product: Product) async throws -> PurchaseResult
}
// 生产环境
final class StoreManager: StoreProtocol {
func products(for ids: [String]) async throws -> [Product] {
try await Product.products(for: ids)
}
}
// 测试环境
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 func testSuccessfulPurchase() async {
let mockStore = MockStore()
let manager = StoreManager(store: mockStore)
// 给定:模拟成功购买
mockStore.mockPurchaseResult = .success(.verified(mockTransaction))
// 当:购买产品
let result = await manager.purchase(mockProduct)
// 那么:授予权益
#expect(result == true)
#expect(manager.purchasedProductIDs.contains("com.app.premium"))
}
@Test func testCancelledPurchase() async {
let mockStore = MockStore()
let manager = StoreManager(store: mockStore)
// 给定:用户取消
mockStore.mockPurchaseResult = .userCancelled
// 当:购买产品
let result = await manager.purchase(mockProduct)
// 那么:未授予权益
#expect(result == false)
#expect(manager.purchasedProductIDs.isEmpty)
}
// ❌ 错误:在没有 .storekit 文件的情况下编写购买代码
let products = try await Product.products(for: productIDs)
// 没有 App Store Connect 设置就无法测试这个!
✅ 正确 : 首先创建 .storekit 文件,在 Xcode 中测试,然后实现。
// ❌ 不太理想:编写代码,在沙盒中测试,稍后添加 .storekit
let products = try await Product.products(for: productIDs)
let result = try await product.purchase(confirmIn: scene)
// "我在沙盒中测试过了,它可以工作!我稍后会添加 .storekit 配置。"
✅ 推荐 : 首先创建 .storekit 配置,然后编写代码。
如果您处于这种情况 : 请参阅上面的"在创建 .storekit 配置之前已经编写了代码?"部分以了解您的选项(A、B 或 C)。
为什么 .storekit 优先更好 :
沙盒测试很有价值 - 它针对真实的 App Store 基础设施进行验证。但从 .storekit 开始可以使沙盒测试更容易,因为您已经在本地验证了产品 ID。
// ❌ 错误:购买调用分散在整个应用中
Button("Buy") {
try await product.purchase() // 在视图 1 中
}
Button("Subscribe") {
try await subscriptionProduct.purchase() // 在视图 2 中
}
✅ 正确 : 所有购买都通过集中化的 StoreManager。
// ❌ 错误:从不调用 finish()
func handleTransaction(_ transaction: Transaction) {
grantEntitlement(for: transaction)
// 缺失:await transaction.finish()
}
✅ 正确 : 在授予权益后始终调用 transaction.finish()。
// ❌ 错误:使用未验证的交易
for await transaction in Transaction.all {
grantEntitlement(for: transaction) // 不安全!
}
✅ 正确 : 在授予权益前始终检查 VerificationResult。
// ❌ 错误:仅在 purchase() 方法中处理购买
func purchase() {
let result = try await product.purchase()
// 那待处理的购买、家庭共享、恢复呢?
}
✅ 正确 : 监听 Transaction.updates 以获取所有交易来源。
// ❌ 错误:没有恢复按钮
// App Store 会拒绝您的应用!
✅ 正确 : 在设置中提供可见的"恢复购买"按钮。
在标记 IAP 实现完成之前,请验证:
运行这些搜索以验证合规性:
# 检查 StoreKit 配置是否存在
find . -name "*.storekit"
# 检查 transaction.finish() 是否被调用
rg "transaction\.finish\(\)" --type swift
# 检查 VerificationResult 使用情况
rg "VerificationResult" --type swift
# 检查 Transaction.updates 监听器
rg "Transaction\.updates" --type swift
# 检查恢复实现
rg "AppStore\.sync|Transaction\.all" --type swift
WWDC : 2025-241, 2025-249, 2023-10013, 2021-10114
文档 : /storekit, /appstoreserverapi
技能 : axiom-storekit-ref
每周安装
118
仓库
GitHub 星标
610
首次出现
Jan 21, 2026
安全审计
安装在
opencode99
codex93
gemini-cli91
claude-code89
cursor87
github-copilot81
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
✅ Use this skill when :
❌ Do NOT use this skill for :
If you wrote purchase code before creating .storekit configuration, you have three options:
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 :
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 :
If choosing this path : Create .storekit immediately, verify locally, and commit a note explaining the approach.
Commit without .storekit configuration, test only in sandbox.
Why this is problematic :
Bottom line : Choose Option A if possible, Option B if pragmatic, never Option C.
Best Practice : Create and test StoreKit configuration BEFORE writing production purchase code.
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 :
Faster iteration :
Team benefits :
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.
StoreKit Config → Local Testing → Production Code → Unit Tests → Sandbox Testing
↓ ↓ ↓ ↓ ↓
.storekit Test purchases StoreManager Mock store Integration test
Why this order helps :
Benefits of following this workflow :
Before marking IAP implementation complete, ALL items must be verified:
.storekit configuration file with all productsTransaction.updatesVerificationResult.finish() after entitlement grantedpurchase(confirmIn:options:) with UI context (iOS 18.2+)PurchaseResult cases (success, userCancelled, pending)Product.SubscriptionInfo.StatusTransaction.currentEntitlements(for:)Transaction.currentEntitlements or Transaction.allDO THIS BEFORE WRITING ANY PURCHASE CODE.
Products.storekit (or your app name)Click "+" and add each product type:
Product ID: com.yourapp.coins_100
Reference Name: 100 Coins
Price: $0.99
Product ID: com.yourapp.premium
Reference Name: Premium Upgrade
Price: $4.99
Product ID: com.yourapp.pro_monthly
Reference Name: Pro Monthly
Price: $9.99/month
Subscription Group ID: pro_tier
Products.storekitAll purchase logic must go through a single StoreManager. No scattered Product.purchase() calls throughout app.
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.
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
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
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
}
}
}
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)")
}
}
}
}
}
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
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:
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
}
}
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
}
}
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
}
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
}
}
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
}
}
struct SubscriptionView: View {
var body: some View {
SubscriptionStoreView(groupID: "pro_tier") {
// Marketing content
VStack {
Image("premium-icon")
Text("Unlock all features")
}
}
.subscriptionStoreControlStyle(.prominentPicker)
}
}
extension StoreManager {
func restorePurchases() async {
// Sync all transactions from App Store
try? await AppStore.sync()
// Update current entitlements
await updatePurchasedProducts()
}
}
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.
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()
}
}
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 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)
}
// ❌ 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.
// ❌ 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 :
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.
// ❌ 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.
// ❌ WRONG: Never calling finish()
func handleTransaction(_ transaction: Transaction) {
grantEntitlement(for: transaction)
// Missing: await transaction.finish()
}
✅ Correct : ALWAYS call transaction.finish() after granting entitlement.
// ❌ WRONG: Using unverified transaction
for await transaction in Transaction.all {
grantEntitlement(for: transaction) // Unsafe!
}
✅ Correct : Always check VerificationResult before granting.
// ❌ 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.
// ❌ WRONG: No restore button
// App Store will REJECT your app!
✅ Correct : Provide visible "Restore Purchases" button in settings.
Before marking IAP implementation complete, verify:
Run these searches to verify compliance:
# 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
WWDC : 2025-241, 2025-249, 2023-10013, 2021-10114
Docs : /storekit, /appstoreserverapi
Skills : axiom-storekit-ref
Weekly Installs
118
Repository
GitHub Stars
610
First Seen
Jan 21, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykWarn
Installed on
opencode99
codex93
gemini-cli91
claude-code89
cursor87
github-copilot81
minimal-run-and-audit:AI论文复现执行与审计技能,标准化测试与报告
6,100 周安装