Реализация In-App Purchases (расходуемые покупки) для iOS
Consumable IAP — монеты, кристаллы, жизни, заряды — покупки, которые тратятся и покупаются снова. В отличие от non-consumable, Apple их не восстанавливает: купленные монеты, потраченные год назад, не вернуть через restoreCompletedTransactions. Ответственность за балансировку виртуальной валюты полностью на разработчике.
Главная проблема: двойное начисление
Consumable-транзакция должна быть обработана ровно один раз. Самый распространённый баг — начислять валюту в paymentQueue(_:updatedTransactions:) и вызывать finishTransaction в том же методе. Если приложение крашнет после начисления, но до finishTransaction — Apple повторно доставит транзакцию при следующем запуске, и пользователь получит монеты дважды.
Правильный порядок при серверной архитектуре:
- Получаем транзакцию в
.purchasedсостоянии - Отправляем
transactionIdentifier+ receipt на свой сервер - Сервер идемпотентно начисляет валюту (проверяет
transactionIdentifierв БД — если уже есть, не начисляет повторно) - После успешного ответа сервера —
finishTransaction
Без шага с идемпотентностью на сервере двойное начисление при краше или нестабильной сети неизбежно.
StoreKit 2 и consumables
В StoreKit 2 consumable-транзакции не попадают в Transaction.currentEntitlements — потому что у них нет «активного» состояния. Они появляются в Transaction.all (полная история), но после finish() — только если transactionID известен.
let result = try await product.purchase()
if case .success(let verification) = result,
case .verified(let transaction) = verification {
// Отправляем на сервер для начисления
let credited = await creditOnServer(transactionId: transaction.id,
receiptData: receiptData)
if credited {
await transaction.finish()
}
// Если сервер недоступен — не финишируем,
// транзакция придёт снова при следующем запуске
}
Офлайн-сценарий
Для игр без постоянного бэкенда — локальное хранение баланса в Keychain с серверной верификацией при следующей онлайн-сессии. При этом транзакцию не финишируем до подтверждения. Но если пользователь никогда не выходит в онлайн — нужен таймаут и локальный fallback, иначе App Review это отклонит (гайдлайн 3.1.1 требует, чтобы купленный контент был доступен).
Тестирование edge cases
В Xcode StoreKit Testing (StoreKitTest framework) можно имитировать сбои транзакций:
let session = try SKTestSession(configurationFileNamed: "Products")
session.simulateAskToBuyInSandbox = false
// Форсируем ошибку для тестирования retry-логики
try session.failTransactionsEnabled = true
Обязательно покрываем: покупка при отсутствии интернета, краш между начислением и finish(), повторный запуск после краша, попытка купить при уже незавершённой транзакции в очереди.
Сроки — 2–3 дня: интеграция StoreKit 2, серверная часть с идемпотентным начислением, тесты на Sandbox.







