Все статьи
Deep dive8 мин чтения

Обнаружение возвратов в StoreKit 2 сводится к одному свойству транзакции, и это revocationDate

Когда Apple возвращает деньги одному из ваших клиентов, возврат уже находится внутри вашего приложения, в свойстве revocationDate транзакции, ещё до того как отработает серверная задача. Вот где обнаружение возвратов StoreKit 2 проявляется на устройстве, что говорят вам revocationDate и revocationReason, и почему клиент нужен для скорости, а сервер для истины.

Смартфон светится на тёмном столе разработчика рядом с механическими часами, иллюстрируя обнаружение возвратов StoreKit 2 внутри приложения

Главное

  • В StoreKit 2 возвращённая покупка несёт непустой revocationDate в своей Transaction, поэтому ваше приложение может обнаружить возврат самостоятельно, без обращения к серверу.
  • revocationDate устанавливается, когда App Store возвращает деньги за транзакцию или когда клиент теряет её через Family Sharing, поэтому непустая дата не всегда означает возврат.
  • revocationReason сообщает причину: developerIssue означает, что клиент указал на проблему в вашем приложении, а other охватывает все остальные причины возврата.
  • Transaction.currentEntitlements уже исключает возвращённые и отозванные покупки, поэтому самый чистый клиентский способ проверить доступ, это просто наличие продукта в этом списке.
  • Transaction.updates доставляет возврат, произошедший при закрытом приложении, только если вы начинаете слушать при запуске, поэтому отсутствующий Task означает пропущенный возврат.
  • Клиентское обнаружение срабатывает только пока приложение открыто, поэтому REFUND из App Store Server Notifications V2 остаётся авторитетным сигналом, который не даёт вам платить за обслуживание клиента с возвратом.
  • Возврат может быть отменён, и когда это происходит, поля отзыва удаляются из транзакции, и вы должны восстановить доступ, который отключили.

Большинство приложений узнают о возврате Apple от своего сервера, через App Store Server Notification, и не замечают, что тот же возврат уже находится внутри приложения. Он в транзакции, в свойстве под названием revocationDate, и чтение его позволяет вашему приложению отключить доступ клиенту с возвратом при следующем открытии, вместо ожидания серверной задачи. Обнаружение возвратов StoreKit 2, это клиентский сигнал, который большинство команд пропускает. Вот где именно возврат проявляется на устройстве, что он вам говорит, чего не говорит, и почему он должен быть рядом с серверными уведомлениями, а не вместо них.

Где возврат появляется внутри StoreKit 2

StoreKit 2 передаёт вам транзакции как подписанные значения, и возврат не удаляет транзакцию. Он помечает её. Два свойства в Transaction несут эту метку, и оба остаются nil на протяжении всей жизни здоровой покупки. Когда одно из них становится непустым, App Store забрал покупку обратно.

revocationDate, это поле, которое меняется

revocationDate, это опциональная Date. Собственное описание Apple точно: это дата, когда App Store вернул деньги за транзакцию или отозвал её из Family Sharing. Для покупки, которая всё ещё в порядке, это nil. В момент обработки возврата оно хранит отметку времени этого возврата. Эта единственная проверка, непустой ли revocationDate, и есть всё клиентское обнаружение возвратов. Всё остальное, это нюансы поверх неё.

revocationReason сообщает, почему Apple отозвал покупку

revocationReason находится рядом с датой и объясняет причину. StoreKit даёт ему два значения, важных для возвратов. developerIssue означает, что клиент сообщил Apple, что возврат был вызван реальной или воспринимаемой проблемой в вашем приложении. other охватывает все остальные причины. Третье значение, upgradedToBundle, вообще не возврат; оно отмечает транзакцию, которую App Store отозвал, потому что клиент перешёл на пакет подписки. Читайте причину, прежде чем действовать, потому что developerIssue, это та, которую стоит считать: их скопление, это ваш собственный продукт, говорящий вам, где он сломался.

СвойствоТипЧто означает непустое значение
revocationDateDate?App Store вернул деньги за эту транзакцию или отозвал её через Family Sharing в эту дату
revocationReason равно developerIssueпричинаКлиент указал на реальную или воспринимаемую проблему в вашем приложении
revocationReason равно otherпричинаВозврат произошёл по какой-то другой причине, которую Apple не детализирует
revocationReason равно upgradedToBundleпричинаНе возврат; транзакция была отозвана, потому что клиент перешёл на пакет подписки

currentEntitlements уже исключает возвращённую покупку

Вам не всегда нужно читать поля отзыва самостоятельно. Transaction.currentEntitlements, это последовательность покупок, на которые клиент всё ещё имеет право прямо сейчас, и Apple строит её так, чтобы оставить за бортом те, которые вы не должны учитывать. Продукт, за который App Store вернул деньги или который отозвал, в ней не появляется. Как и истёкшие подписки или расходуемые товары, которые исчезают в момент, когда они использованы.

Это делает currentEntitlements самым чистым способом контроля доступа. Спросите его, чем владеет клиент, предоставьте ровно это, и возврат уберёт право за вас без единой проверки revocationDate. Поля отзыва нужны, когда вам нужны детали, дата и причина, чтобы записать событие или отреагировать на него. Список прав нужен для простого вопроса, оставлять ли свет включённым.

Обнаружение возвратов StoreKit 2 на практике, при запуске и во время работы приложения

Есть два момента, когда ваше приложение может поймать возврат на устройстве, и им нужен разный код. Один, пока приложение открыто и возврат происходит вживую или на другом устройстве. Другой, при запуске, догоняя всё, что изменилось, пока вы были закрыты. Пропустите второй, и в вашем обнаружении возвратов StoreKit 2 будет дыра ровно там, куда попадает большинство возвратов, потому что у клиентов редко открыто ваше приложение, когда они просят возврат.

Начните слушать при запуске, иначе вы пропустите возвраты, произошедшие при закрытом приложении

Transaction.updates, это асинхронная последовательность, которая выдаёт транзакцию всякий раз, когда система создаёт или обновляет её вне вашего приложения или на другом устройстве, включая возврат. Инструкция Apple прямая: запустите Task, который перебирает её, как только ваше приложение запускается, иначе вы можете пропустить транзакции, которые она доставляет только один раз при старте. Возврат, случившийся ночью, приходит через updates при следующем открытии приложения, но только если слушатель уже работает, чтобы его принять. Нет слушателя, нет события, и возврат остаётся невидимым, пока что-то ещё его не сверит.

Покупка на том же устройстве не приходит через updates

Одна ловушка ловит тех, кто тестирует возвраты вручную. Обычная покупка, сделанная на том же устройстве, не приходит через updates; StoreKit возвращает её прямо из результата вызова покупки. updates нужен для внеполосных изменений: возвратов, одобрений Ask to Buy, погашений промокодов и покупок, сделанных в другом месте. Поэтому стройте обработку возвратов вокруг updates и currentEntitlements, а не вокруг процесса покупки, потому что возврат никогда не вернётся тем путём, которым прошла продажа.

Смятый бумажный чек на тёмной поверхности со слабым красным штампом поперёк него, символизирующий возвращённую транзакцию, которую StoreKit 2 помечает датой отзыва

Чего клиентское обнаружение не может сделать за вас

Чтение возвратов на устройстве быстро и бесплатно, но у него есть потолок, и притворяться, что его нет, значит терять доход. Устройство знает только то, что ему сказал StoreKit, а StoreKit говорит только пока ваше приложение работает. Клиент, который получил возврат и больше никогда не открыл ваше приложение, это клиент, которого ваша клиентская проверка никогда не увидит.

revocationDate не всегда возврат

То же поле меняется из-за Family Sharing. Когда клиент теряет доступ к общей покупке, потому что организатор удалил его или обмен закончился, эта транзакция тоже получает revocationDate. Поэтому непустая дата означает, что у клиента больше нет этой покупки, что как раз то, что вам нужно для контроля доступа, но это не всегда означает, что деньги вернулись. Если вы считаете возвраты для дохода, отделите отзывы Family Sharing от настоящих, прежде чем доверять цифре.

Возврат может быть отменён

Возврат не всегда окончателен. Apple может его отменить, и когда это происходит, поля отзыва удаляются из транзакции, и покупка снова действительна. Если вы отключили доступ при возврате, вы должны восстановить его при отмене. На устройстве это проявляется как ещё одно событие updates с чистой транзакцией; на вашем сервере это отдельное уведомление REFUND_REVERSED. Обработайте только возврат, и вы оставите платящего клиента без доступа, но с работающим чеком.

Во что на самом деле обходится поздний отзыв

Возврат редко бывает просто ценой продажи, уходящей с вашего счёта. К тому времени, когда он проходит, вы обычно уже потратили реальные деньги на обслуживание этой покупки, и эти траты не возвращаются. Сгенерированные изображения стоят минут работы GPU. Ответы чата стоят вызовов API модели, за которые вам выставили счёт за каждый токен. Загрузки стоят хранилища, за которое вы всё ещё платите. Если покупка профинансировала выплату автору, эти деньги уже ушли. Ничего из этого не возвращается с возвратом.

Клиентское обнаружение сокращает окно на той единственной части, которую вы всё ещё можете контролировать, это будущие траты. Чем раньше вы узнаете, что за покупку вернули деньги, тем раньше вы перестанете её обслуживать. Но устройство сообщает вам только пока приложение открыто, поэтому клиент с возвратом, который никогда не вернётся, сохраняет любой серверный доступ, который вы предоставили, тихо обходясь вам в копеечку каждый раз, когда фоновая задача или синхронизированное устройство действуют от его имени. Клиент делает отзыв быстрым. Он не делает его гарантированным.

Используйте клиент для скорости, а сервер для истины

Чистая архитектура использует оба сигнала для того, в чём каждый хорош. На устройстве Transaction.updates и currentEntitlements дают вам мгновенную локальную реакцию в момент, когда клиент с возвратом открывает приложение, что хорошо для интерфейса и для завершения изменений прав без обращения к серверу. На сервере App Store Server Notifications V2 отправляют сообщение REFUND, которое приходит независимо от того, откроют ли приложение снова, и это единственный сигнал, который надёжно останавливает траты вашего бэкенда на аккаунт с возвратом.

СигналГде живётСрабатывает когдаДоверяйте ему
revocationDate в транзакцииУстройство, StoreKit 2Ваше приложение читает транзакциюСообщить, что конкретная покупка была возвращена или отозвана
Transaction.updatesУстройство, StoreKit 2Возврат приходит, пока приложение работает, или при запуске, если вы слушаетеМгновенно отреагировать для присутствующего клиента
currentEntitlementsУстройство, StoreKit 2Вы проверяете, чем клиент владеет сейчасКонтролировать доступ, не отслеживая возвраты самостоятельно
REFUND уведомлениеВаш сервер, App Store Server Notifications V2Apple обрабатывает возврат, открыто приложение или нетОстановить серверные траты на клиента, который никогда не вернётся

Настройте сигналы устройства для клиента, который держит телефон, и серверное уведомление для того, кто не держит. Возврат появляется в обоих местах намеренно. Читать только один из них, это способ, которым аккаунт с возвратом продолжает обходиться вам после того, как продажа уже ушла.

Частые вопросы

Как обнаружить возврат в StoreKit 2?
Проверьте revocationDate транзакции. Он равен nil для действительной покупки и хранит дату, как только App Store возвращает деньги за транзакцию, поэтому непустой revocationDate, это сигнал, что за покупку вернули деньги или её отозвали.
В чём разница между revocationDate и revocationReason?
revocationDate, это когда App Store забрал покупку обратно, а revocationReason, это почему. Причина, это developerIssue, когда клиент указал на проблему в вашем приложении, и other для всего остального.
Появляется ли возвращённая покупка в currentEntitlements?
Нет. Transaction.currentEntitlements исключает покупки, за которые App Store вернул деньги или которые отозвал, поэтому возвращённый продукт сам выпадает из прав клиента, что делает его безопасным способом контроля доступа.
Сообщит ли StoreKit моему приложению о возврате, произошедшем при закрытом приложении?
Только если вы слушаете с запуска. Transaction.updates доставляет эти изменения один раз при старте, поэтому вы должны запустить Task, перебирающий её при запуске приложения, иначе возврат будет пропущен, пока что-то ещё его не сверит.
Достаточно ли клиентского обнаружения возвратов самого по себе?
Нет. Устройство узнаёт о возврате только пока ваше приложение работает, поэтому клиент, который никогда не откроет приложение снова, для него невидим. REFUND из App Store Server Notifications V2, это сигнал, который доходит до вас в любом случае.
Всегда ли revocationDate означает, что клиенту вернули деньги?
Нет. revocationDate также устанавливается, когда клиент теряет покупку через Family Sharing, поэтому непустая дата означает, что у него больше нет покупки, но не всегда что деньги вернулись.

Источники и дополнительное чтение

RefundHalt

Автопилот возвратов для App Store и Google Play

Читайте дальше

Следующий запрос на возврат уже в пути.

Настройте RefundHalt за то время, что уходит на чтение очередного письма в поддержку о возврате, который вы не успели оспорить.