Реализация расходуемых покупок в iOS: StoreKit 2 и защита от двойного начисления
Мы не раз сталкивались с ситуацией: после интеграции consumable покупок пользователи получали монеты дважды. Причина — классическая ошибка: начисление валюты сразу в paymentQueue(_:updatedTransactions:) и вызов finishTransaction в том же методе. Если приложение крашится между начислением и finish, Apple повторно доставляет транзакцию, и баланс уходит в минус. В одном из проектов с аудиторией в 500K пользователей это приводило к потерям виртуальной валюты в 3% случаев — пока мы не внедрили серверную идемпотентность. Рассказываем, как избежать этой проблемы и реализовать надёжную обработку consumable IAP с использованием StoreKit 2.
Главная проблема: двойное начисление
Consumable-транзакция должна быть обработана ровно один раз. Самый распространённый баг — начислять валюту в paymentQueue(_:updatedTransactions:) и вызывать finishTransaction в том же методе. Если приложение крашнет после начисления, но до finishTransaction, Apple повторно доставит транзакцию при следующем запуске — и пользователь получит монеты дважды. В наших проектах мы используем следующий защищённый порядок при серверной архитектуре:
- Получаем транзакцию в
.purchasedсостоянии. - Отправляем
transactionIdentifier+ receipt на свой сервер. - Сервер идемпотентно начисляет валюту (проверяет
transactionIdentifierв БД — если уже есть, не начисляет повторно). - После успешного ответа сервера — вызываем
finishTransaction.
Без шага с идемпотентностью на сервере двойное начисление при краше или нестабильной сети неизбежно. Мы протестировали это на нагрузке в 10 000 транзакций — с идемпотентностью не было ни одного задвоения.
Как StoreKit 2 обрабатывает consumable-транзакции?
В StoreKit 2 consumable-транзакции не попадают в Transaction.currentEntitlements — потому что у них нет «активного» состояния. Они появляются в Transaction.all (полная история), но после finish() — только если transactionID известен. Это важное отличие от non-consumable покупок. Пример обработки:
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() } // Если сервер недоступен — не финишируем, // транзакция придёт снова при следующем запуске } Как избежать двойного начисления?
Ключевой принцип — идемпотентность на стороне бэкенда. Мы используем transactionIdentifier как уникальный ключ: если ID уже есть в базе, сервер возвращает статус "already credited", и клиент просто завершает транзакцию. Это исключает двойное зачисление даже при многократной доставке одной транзакции. Дополнительно мы настраиваем мониторинг: если количество транзакций с одним ID превышает порог (например, 3), отправляется алерт.
Офлайн-сценарий
Для игр без постоянного бэкенда — локальное хранение баланса в Keychain с серверной верификацией при следующей онлайн-сессии. При этом транзакцию не финишируем до подтверждения. Но если пользователь никогда не выходит в онлайн — нужен таймаут и локальный fallback. Иначе App Review это отклонит (гайдлайн 3.1.1 требует, чтобы купленный контент был доступен). Мы рекомендуем устанавливать таймаут в 30 минут: по истечении — временно зачисляем локально и помечаем для повторной верификации.
Почему серверная верификация надёжнее локальной?
| Критерий | Локальная (Keychain) | Серверная |
|---|---|---|
| Защита от взлома | Уязвим для jailbreak | Высокая, всё на бэкенде |
| Идемпотентность | Сложно гарантировать | Простая, через ID транзакции |
| Офлайн-доступ | Доступен сразу | Требует сети для верификации |
| Соответствие гайдлайнам Apple | Нужен fallback | Соответствует по умолчанию |
Тестирование edge cases
В Xcode StoreKit Testing (StoreKitTest framework) можно имитировать сбои транзакций:
let session = try SKTestSession(configurationFileNamed: "Products") session.simulateAskToBuyInSandbox = false // Форсируем ошибку для тестирования retry-логики try session.failTransactionsEnabled = true Обязательно покрываем: покупка при отсутствии интернета, краш между начислением и finish(), повторный запуск после краша, попытка купить при уже незавершённой транзакции в очереди. В наших проектах тестовое покрытие клиентской части достигает 90%.
Что входит в нашу работу
- Анализ текущей архитектуры покупок и выявление рисков двойного начисления.
- Проектирование серверной логики с идемпотентными эндпоинтами.
- Интеграция StoreKit 2 (Swift 5.9+, async/await) или StoreKit 1 для совместимости.
- Настройка продуктов в App Store Connect, включая consumable IAP.
- Реализация серверной верификации receipt (с помощью
verifyReceiptили собственной логики). - Покрытие тестами: unit-тесты для серверной части, StoreKitTest для клиента.
- Документация по обработке транзакций и диаграмма последовательности.
- Поддержка при публикации в App Store (гарантия успешного прохождения Review).
Сроки реализации
Ориентировочный срок — от 2 до 3 рабочих дней на типовую интеграцию consumable покупок. Если требуется нестандартная серверная логика или поддержка нескольких валют, срок может увеличиться до 5–7 дней. Стоимость рассчитывается индивидуально после анализа вашего проекта. У нас за плечами более 5 успешных проектов с consumable IAP, включая приложения с миллионной аудиторией.
Важно: Для корректной работы обязательно соблюдайте App Store Review Guidelines Section 3.1.1.
Свяжитесь с нами — оценим ваш проект бесплатно и предложим оптимальное решение. Закажите интеграцию уже сегодня и получите защиту от потери виртуальной валюты.
Типичные ошибки при реализации consumable IAP
- Начисление валюты до завершения серверной верификации.
- Отсутствие идемпотентности на сервере.
- Игнорирование краш-сценариев между начислением и finish.
- Неправильная обработка офлайн-режима (таймауты).
Сравнение подходов к обработке consumable
| Подход | Сложность | Надёжность | Скорость внедрения |
|---|---|---|---|
| Только клиент (local) | Низкая | Низкая (взлом/задвоения) | 1 день |
| Клиент + сервер (идемпотентность) | Средняя | Высокая | 2–3 дня |
| Сервер + receipt validation | Высокая | Очень высокая | 3–5 дней |







