iOSでアプリ内課金を実装する（サーバーサイド）

# はじめに

個人的な備忘として、iOSアプリでアプリ内課金を実装する際のHowToやノウハウを残しておく。

**動いていることは確認しているが、あまり洗練されている気がしない。。。**
**[App Store Server Notifications](https://developer.apple.com/documentation/appstoreservernotifications) を利用すれば、多分、もう少し良い具合に実装できそう。**
**ベストプラクティスだとは思わないで頂けると。。。**


# 処理

以下、時系列に従って実装の概要・勘所を説明する。


## 1. アプリ内課金を実施する

割愛（サーバーから商品情報取得したり、App Storeで決済したり）


## 2. アプリでレシートを取得する

アプリ側の実装として、以下のコードを参考にApp Storeからレシートを取得する。
取得したレシートは、サーバーへ送信し、検証・商品提供を行う。

```swift
// エラーハンドリングは割愛
let url = Bundle.main.appStoreReceiptURL
let data = Data(contentsOf: url, options: .alwaysMapped)
let receipt = data.base64EncodedString()
```


## 3. サーバーでレシート検証を行う

App Store [verifyReceipt](https://developer.apple.com/documentation/appstorereceipts/verifyreceipt) を実行して、処理項番2で取得したレシートの検証を行う。

参考: [Validating Receipts with the App Store | Apple Developer Documentation](https://developer.apple.com/documentation/storekit/in-app_purchase/original_api_for_in-app_purchase/validating_receipts_with_the_app_store)

以下、URLにPOSTすると、検証結果、及びトランザクション情報が返却される。

| 環境 | URL |
| --- | --- |
| Production | `https://buy.itunes.apple.com/verifyReceipt` |
| Sandbox | `https://sandbox.itunes.apple.com/verifyReceipt` |

**パラメーター**

| キー | データ | 備考 |
| --- | --- | --- |
| `receipt-data` | 処理項番2で取得したレシート | - |
| `password` | iTunes Connectのキー | サブスクのみ |
| `exclude-old-transactions` | `true`<br>（最新のトランザクション情報のみ取得） | サブスクのみ |

**レスポンス**

公式ドキュメント参照。

### Appleレビュアー対策

Appleレビュアーは、Sandbox環境のレシートを送信してくる。
Productionに対して、Sandboxレシートを送信すると`status`が`21007 (環境不整合)`になり、処理できない。

<font color="Red">課金処理が正常終了しないと、不具合ありとしてレビュー通過できないため、考慮が必要。</font>

考えられる解決策は、以下3つ。

#### 1. User-Agentを利用したバージョンチェック

User-Agentのアプリバージョンが公開前バージョンであれば、Appleレビュアーの検証によるものとみなし、SandboxでverifyReceiptを行う。

- メリット
    - 直感的な実装になる（と思う）
- デメリット
    - 開発側で公開前バージョンアプリを利用した、決済テストができない
    - アプリバージョンの管理が必要

#### 2. verifyReceiptの実行結果に応じてハンドリング

一旦、ProductionでverifyReceiptを行う。`status = 21007`だった場合はSandboxで再実行する。

- メリット
    - 案1のデメリットが解消できる
    - 比較的、少ない手間で対応可能
- デメリット
    - 実装がスッキリしない
    - Appleレビュアー以外からの不正なリクエストだった場合、検出しにくい

#### 3. テストユーザーとして管理する

Appleへアプリ審査を依頼する際に、テストユーザーの認証情報を指定することができる。
サービスの本番環境向けテストユーザー管理の仕組みに組み込む。

- メリット
    - 案1, 2のデメリットを解消できる
    - テスト容易性が良い
    - あるべき論的にも良いかも
- デメリット
    - 途中から導入すると、かなり手間がかかる


## 4. トランザクション検証を行う

処理項番3で取得したトランザクション情報を検証し、商品の提供可否を判定する。

### サブスクリプションの場合

verifyReceiptの結果（以降`verifyReceipt`とする）の`latest_receipt_info[]`にて評価する。

以下、`verifyReceipt.latest_receipt_info[n]`の主な評価ポイント。

| キー | 概要 | チェック方法 |
| --- | --- | --- |
| `product_id` | 商品ID | サービス側で定義している<br>商品IDと一致するか、チェックする |
| `original_transaction_id` | 購入処理トランザクションID<br>新たに課金する都度、付与されるID | 既に管理されている（DBに存在する）IDの場合、<br>新規購入ではない可能性がある |
| `transaction_id` | トランザクションID<br>新規・継続など支払い都度付与されるID | 既に管理されている（DBに存在する）IDの場合、<br>商品提供が済んでいる可能性がある |
| `expires_date` | 期限日時 | 期限切れかチェックする |
| `cancellation_date` | キャンセル日時 | 有効な日時が設定されている場合<br>キャンセルされている |

:::note warn
**latest_receipt_infoの取り扱い**
新しい順・古い順が変わることがあるらしい。順不同と思って扱うべき。
また、同一商品の通常価格品・セール品が有効になってるとか、不整合アラート目的で全てのレコードをチェックすると安心。
:::

### ポイントなど消費系商品の場合

verifyReceiptの`receipt.in_app[]`にて評価する。

以下、`verifyReceipt.receipt.in_app[n]`の主な評価ポイント。

| キー | 概要 | チェック方法 |
| --- | --- | --- |
| `product_id` | 商品ID | サービス側で定義している<br>商品IDと一致するか、チェックする |
| `transaction_id` | トランザクションID<br>支払い都度付与されるID | 既に管理されている（DBに存在する）IDの場合、<br>商品提供が済んでいる可能性がある |
| `cancellation_date` | キャンセル日時 | 有効な日時が設定されている場合<br>キャンセルされている |


## 5. レシート情報を保管

後述のサブスク継続処理用にレシート情報を保管する。

`verifyReceipt.latest_receipt`をDBなどに保管しておき、後の継続処理で利用する。


## 6. 商品提供を行う

サービスの要件に応じた商品提供を行う。


## 7. サブスクを継続する

サブスク期限の到来時に、処理項番5で保管しておいたレシート情報に基づいて、以下を実行する。
- [4. トランザクション検証を行う](#4-トランザクション検証を行う)
- [5. レシート情報を保管](#5-レシート情報を保管)
- [6. 商品提供を行う](#6-商品提供を行う)


# ノウハウ

**課金の復元**

- 復元機能を用意する
ユーザーが任意のタイミングで、課金状態を反映する機能を用意しないと、Appleアプリ審査通過できない。
前述の流れに従って、ユーザーに提供できていない商品をチェック・提供する機能が必要。

**商品提供関連**

- 事前チェック
ユーザーの状態（性別や年齢層など）に応じて、提供商品が異なる場合、早めにチェックする。
[6. 商品提供を行う](#6-商品提供を行う) でエラーを返しても、決済が取り消されることはない。
返金対応などの業務が発生するため、アプリ側で課金する前に防ぐ。

- 返金の考慮
iOSアプリでの課金はユーザーが直接Appleに対して返金をリクエストできる。
サービスとApple側でユーザーの課金状態にズレが生じる可能性があるため、考慮が必要。
例えば、以下の方法で対応する。
　・アプリ起動時に必ず課金状態をチェックするようにして、同期を取る
　・バッチを用意して全Apple決済サブスクのチェックを定期的に行う
　・[App Store Server Notifications](https://developer.apple.com/documentation/appstoreservernotifications) を利用する？

- 決済保留の考慮
カード有効期限切れなどが原因で、Apple側で決済が保留になる可能性がある。
ユーザーが決済情報を更新した場合、引き続きサブスクが有効になるため、一定期間監視が必要。
支払いが認められた場合、トランザクション情報が更新されるため、商品提供を行う必要がある。

- 商品の多重提供対策
[商品提供を行う前に、トランザクションIDを利用した提供済みチェックは慎重に行う。](#4-トランザクション検証を行う)
同一AppleIDを決済アカウントとして、複数のアカウント登録しているユーザーがいる可能性がある。
ユーザーID×トランザクションIDで当該チェックを実施すると、同一決済の商品を多重に提供しかねない。
トランザクションIDで一意とする方が良いと思う。

**Sandbox関連**

- Sandboxのサブスク期限
Sandboxのサクブス期限は5〜60分程度と短い。
サービス側でサブスク期限を独自に定義している場合（※）は、検証方法の考慮が必要。
※トランザクション情報の期限の当日23:59までなど

- 売上計上関連
[前述の通り、AppleレビュアーはSandboxレシートを送信してくる。](#appleレビュアー対策)
課金処理で売上関連テーブルに登録している場合は、注意が必要。（売り上げとして計上すべきでない）

**本番検証関連**

- 本番検証用のカード
本番検証を行う際に、AppleIDに法人カードを紐付けると課金できない可能性がある。
iTunesカード（Apple Gift Card）などを利用するか、個人のカードを利用して後で請求するとか、考慮が必要。