StoreKit 2 でのアプリ内課金の基本

本記事内で使っているワークアラウンドコードの解決策はこちらの記事に書いてあります

https://qiita.com/ozumin/items/2f702a50027226bfe122

# 概要
StoreKit 2 でのアプリ内課金の基本をローカルで動くコードと共にまとめます。
勉強した順にまとめているので分かりにくいかもしれません。

<!--
# memo
やりたいこと

- Product
    - 各商品タイプ（auto-renewable, non-renewable）
    - Product.PurchaseOption
        - promotionalOffer (auto-renewable)
        - simulatesAskToBuyInSandbox parental controlの機能をテストするときに使う
    - currentEntitlement
- Transaction
  - beginRefundRequest 払い戻し
- AppStore
  - showManageSubscriptions 今のサブスク確認するシートを出す
  - canMakePayments ユーザーが購入できるか
  - sync 復元ボタンの処理。普段は自動で同期されるのでこれを毎回行う必要はない

🤔
- Product
    - Product.PurchaseOption
        - appAccountToken 自前サーバーとデータを紐づけたい時にUUIDを設定
        - onStorefrontChange storefrontが変わった時に購入を続けるか
        - custom(型4つ) 将来的に使うデータを保持
    - Product.PurchaseError
    - currentEntitlement (consumable 以外)
    - latestTransaction
    - subscription (auto-renewable)
    - Product.SubscriptionInfo
    - isFamilyShareable
-->

# 準備

今回は Xcode でのテストをします。Xcode と Sandbox のテストでできることの比較は[こちら](https://developer.apple.com/documentation/storekit/in-app_purchase/testing_at_all_stages_of_development_with_xcode_and_sandbox/)。

途中で戸惑ったのですが、Xcode でのテストでは、再インストール時の自動復元の確認などができないようです：https://stackoverflow.com/questions/69768519/appstore-sync-not-restoring-purchases

- 適当なサンプルアプリを作る
- [Setting Up StoreKit Testing in Xcode](https://developer.apple.com/documentation/xcode/setting-up-storekit-testing-in-xcode) に従って StoreKit Configuration file を作成する
- StoreKit Configuration file を開いたまま Storefront を日本にする <img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/747207/5850dc7b-0527-dc1f-01f1-c426a8a6e04e.png width=500>


# アプリ内課金の基本
## 商品を取得する

```swift
import StoreKit

var productList: [Product]
productList = try await Product.products(for: ["consumable.apple"])
```

StoreKit Configuration file に定義した Product id を指定して、Product を取得できる。
返り値は `[Product]`（[Product のドキュメント](https://developer.apple.com/documentation/storekit/product)）
（簡単のため `consumable` だけで試していく）

<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/747207/3a318479-e34c-2d17-6222-5310ff647522.png width=300>

<details><summary>実際のコード</summary><div>

**StoreKit Configuration File**
![Screen Shot 2022-07-30 at 12.38.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/747207/6643e8d2-902e-61e9-e02f-58e571afc5ba.png)

**`Store` という class で StoreKit とやりとりしています**
```swift
import Foundation
import StoreKit

class Store: ObservableObject {

    @Published var productList: [Product]

    init() {
        productList = []

        Task {
            await fetchProducts()
        }
    }

    @MainActor
    func fetchProducts() async {
        do {
            productList = try await Product.products(for: ["consumable.apple"])
        } catch {
            print("error")
        }
    }
}
```

**UI は最低限のものを用意するとこんな感じ**
```swift
import SwiftUI

struct ContentView: View {

    @StateObject var store: Store = Store()

    var body: some View {
        List {
            Section("食品") {
                ForEach(store.productList) { product in
                    HStack {
                        Text("\(product.displayName)")

                        Spacer()

                        Button {
                            // todo
                        } label: {
                            Text("購入")
                        }.buttonStyle(BorderlessButtonStyle())
                    }
                }
            }
            .listStyle(GroupedListStyle())
        }
    }
}
```
</div></details>

## 商品の情報を表示する
- 商品名 `product.displayName`
- 商品の説明 `product.description`
- 商品の値段（表示用） `product.displayPrice` (`String`)
- 商品の値段（計算用） `product.price` (`Decimal`)

<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/747207/0fe1e56d-0954-aa1d-cced-f74e5142ff74.png width=300>

<details><summary>実際のコード</summary><div>

**UI**


```swift
struct ContentView: View {

    @StateObject var store: Store = Store()

    var body: some View {
        List {
            Section("食品") {
                ForEach(store.productList) { product in
                    HStack(spacing: 2) {
                        Text(product.displayName)

                        Spacer()

                        Text(product.description)
                            .font(.caption)

                        Spacer()

                        VStack {
                            Button {
                                // todo
                            } label: {
                                Text(product.displayPrice)
                            }.buttonStyle(BorderlessButtonStyle())

                            Text("2つ買うと￥\(String(describing: product.price * 2))")
                                .font(.caption2)
                        }
                    }
                }
            }
            .listStyle(GroupedListStyle())
        }
    }
}
```
</div></details>

## 商品の購入
購入自体は `product.purchase` でできる。結果は
- `case success(VerificationResult)`
- `case pending`
- `case userCancelled`

の3つがあるため、それぞれをハンドリングする。
また、`success` の `VerificationResult` も、
- `case verified`
- `case unverified`

があるためそれぞれハンドリングする。
`unverified` の時は自分で verify する方法もあると [Transaction](https://developer.apple.com/documentation/storekit/transaction) に書いてある。

購入の関数は例えば以下のようになる：

```swift
func purchase(_ product: Product) async throws {
    let result = try await product.purchase()

    switch result {
    case let .success(verificationResult):
        switch verificationResult {
        case let .verified(transaction):
            // 購入情報を更新する
            switch transaction.productType {
            case .consumable:
                // 例えば UserDefaults に購入数をいれる
                let amount = UserDefaults.standard.integer(forKey: transaction.productID)
                UserDefaults.standard.set(amount + 1, forKey: transaction.productID)
            default:
                break
            }

            // transaction を終了させる必要がある
            await transaction.finish()

        case let .unverified(_, verificationError):
            // StoreKit の validation が通らなかった場合
            // 自分で validate することができる
            throw verificationError
        }
    case .pending:
        // ユーザーのアクションが必要
        // transaction が終わったら Transaction.updates から取れる
        break
    case .userCancelled:
        // ユーザーがキャンセルした
        break
    @unknown default:
        break
    }
}
```

Consumable 以外はレシートから購入情報を取得できるが、consumable だけは購入情報をアプリ側で保持しなければいけない。そのため、UserDefaults に格納している。

この関数を使うだけでは、以下のような warning が出るため、アプリ起動時に Transaction を取得する必要がある（次のセクション）
![Screen Shot 2022-07-31 at 16.46.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/747207/07dc597a-3771-9199-161e-076e357fc875.png)

## 購入情報を更新する

以下のような状況があるので、アプリ起動時に購入情報を更新する必要がある。
- 終了していない transaction があった時
- アプリ起動中に transaction の更新があった時
  - 他のデバイスで購入した時など

これは `Transaction.updates` で取得でき、そのための `Task` を用意してアプリ起動時に実行するようにする。

```swift
class Store: ObservableObject {

    private var updates: Task<Void, Never>? = nil

    init() {
        updates = newTransactionListenerTask()
    }

    deinit {
        updates?.cancel()
    }

    private func newTransactionListenerTask() -> Task<Void, Never> {
        Task(priority: .background) {
            for await verificationResult in Transaction.updates {
                guard
                    case .verified(let transaction) = verificationResult,
                    transaction.revocationDate == nil // 払い戻されたものは無視する
                else { continue }

                // 購入情報の更新
                await refreshPurchasedProducts()

                await transaction.finish()
            }
        }
    }
```

`refreshPurchasedProducts()` については次のセクションで。

## 最新の購入情報の取得

`Transaction.currentEntitlements` で最新の購入情報を取得できる。
取得できる情報は以下：
- non-consumable それぞれの transaction
- アクティブな auto-renewable subscription の最新の transaction
- non-renewing subscription の最新の transaction
- `finish()` を呼んでいない consumable の transaction 全て

購入後やアプリ起動後に購入情報を更新する必要があるが、その関数はこのような感じになる：

```swift
func refreshPurchasedProducts() async {
    for await verificationResult in Transaction.currentEntitlements {

        // 取り消しや払い戻された transaction は currentEntitlements には現れないので
        // transaction.revocationDate はチェックしなくて良い
        guard case .verified(let transaction) = verificationResult else { continue }

            switch transaction.productType {
            case .consumable:
                // transaction が終了していなかった consumable が来る
                let amount = UserDefaults.standard.integer(forKey: transaction.productID)
                UserDefaults.standard.set(amount + 1, forKey: transaction.productID)
            default:
                continue
        }
    }
}
```

↓このワークアラウンドの解決には [Consumable Product の Ask to Buy 対応](https://qiita.com/ozumin/items/2f702a50027226bfe122) を見てください
~~これで consumable の購入個数を更新できるかと思ったが、transaction が `finish()` していない consumable があっても `currentEntitlements` で取得できなかった。~~
~~[この記事](https://iosexample.com/implementing-and-testing-in-app-purchases-with-storekit2-in-xcode-13-swift-5-5-and-ios-15/) でも同様のことが述べられていた：`In tests I've done transactions for consumables do not remain in the receipt, even if you omit to call finish().`~~

~~なので、購入時に consumable の数を更新することにした（Apple の出しているサンプルアプリでも同様にしていた）~~


```swift
    func purchase(_ product: Product) async throws {
        let result = try await product.purchase()

        switch result {
        case let .success(verificationResult):
            switch verificationResult {
            case let .verified(transaction):
                // 購入情報を更新する
                await refreshPurchasedProducts()

                // consumable については refreshPurchasedProducts() で更新できなかったのでここで更新
                // currentEntitlements の説明では更新できそうに思えたができなかった
                if case .consumable = transaction.productType {
                    let amount = UserDefaults.standard.integer(forKey: transaction.productID)
                    UserDefaults.standard.set(amount + 1, forKey: transaction.productID)
                }

                // transaction を終了させる必要がある
                await transaction.finish()

        （中略）
        }
    }
```

<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/747207/946171eb-5d27-e135-91b7-9a9dd6294d53.gif width=300>

<details><summary>現時点でのコード</summary><div>

**UI (Cellを分けました)**

```swift:ContentView.swift
import SwiftUI

struct ContentView: View {

    @StateObject var store: Store = Store()

    var body: some View {
        List {
            Section("食品") {
                ForEach(store.productList) { product in
                    ConsumableCellView(product: product)
                        .environmentObject(store)
                }
            }
            .listStyle(GroupedListStyle())
        }
    }
}
```

```swift:ConsumableCellView.swift

import SwiftUI
import StoreKit

struct ConsumableCellView: View {
    @EnvironmentObject var store: Store
    let product: Product
    @State private var showAlert = false
    @State private var errorMessage = ""
    @AppStorage var amount: Int

    init(product: Product) {
        self.product = product
        self._amount = AppStorage(wrappedValue: 0, product.id)
    }

    var body: some View {
        HStack(spacing: 2) {
            Text(product.displayName)

            Spacer()

            Text(product.description)
                .font(.caption)

            Spacer()

            VStack {
                Button {
                    Task {
                        await purchase()
                    }
                } label: {
                    Text(product.displayPrice)
                }.buttonStyle(BorderlessButtonStyle())

                Text("2つ買うと￥\(String(describing: product.price * 2))")
                    .font(.caption2)
            }

            Spacer()

            Text("所持数：\(amount)")
                .font(.caption2)
        }
        .alert(errorMessage, isPresented: $showAlert) {
            Button("OK", role: .cancel) { }
        }
    }

    func purchase() async {
        do {
            try await store.purchase(product)
        } catch {
            showAlert = true
            errorMessage = "購入に失敗しました"
        }
    }
}
```

**Store**

```swift:Store.swift
import Foundation
import StoreKit

class Store: ObservableObject {

    @Published var productList: [Product]

    private var updates: Task<Void, Never>? = nil

    init() {
        productList = []

        Task {
            await fetchProducts()
        }

        updates = newTransactionListenerTask()
    }

    deinit {
        updates?.cancel()
    }

    private func newTransactionListenerTask() -> Task<Void, Never> {
        Task(priority: .background) {
            for await verificationResult in Transaction.updates {
                guard
                    case .verified(let transaction) = verificationResult,
                    transaction.revocationDate == nil
                else { continue }

                // 購入情報の更新
                await refreshPurchasedProducts()

                await transaction.finish()
            }
        }
    }

    @MainActor
    func fetchProducts() async {
        do {
            productList = try await Product.products(for: ["consumable.apple"])
        } catch {
            print("error")
        }
    }

    func purchase(_ product: Product) async throws {
        let result = try await product.purchase()

        switch result {
        case let .success(verificationResult):
            switch verificationResult {
            case let .verified(transaction):
                // 購入情報を更新する
                await refreshPurchasedProducts()

                // consumable については refreshPurchasedProducts() で更新できなかったのでここで更新
                // currentEntitlements の説明では更新できそうに思えたができなかった
                if case .consumable = transaction.productType {
                    let amount = UserDefaults.standard.integer(forKey: transaction.productID)
                    UserDefaults.standard.set(amount + 1, forKey: transaction.productID)
                }

                // transaction を終了させる必要がある
                await transaction.finish()

            case let .unverified(_, verificationError):
                // StoreKit の validation が通らなかった場合
                // 自分で validate することができる
                throw verificationError
            }
        case .pending:
            // ユーザーのアクションが必要
            // transaction が終わったら Transaction.updates から取れる
            break
        case .userCancelled:
            // ユーザーがキャンセルした
            break
        @unknown default:
            break
        }
    }

    func refreshPurchasedProducts() async {
        for await verificationResult in Transaction.currentEntitlements {

            // 取り消しや払い戻された transaction は currentEntitlements には現れないので
            // transaction.revocationDate はチェックしなくて良い
            guard case .verified(let transaction) = verificationResult else { continue }

            switch transaction.productType {
            case .consumable:
                // transaction が終了していなかった consumable が来る
                let amount = UserDefaults.standard.integer(forKey: transaction.productID)
                UserDefaults.standard.set(amount + 1, forKey: transaction.productID)
            default:
                continue
            }
        }
    }
}
```
</div></details>

## 複数個購入する（consumable, non-renewing subscription）

商品を購入する際、`Product.purchase(options:)` の引数に `Set<Product.PurchaseOption>` を渡すことで、購入時のオプションを指定できる。
購入個数を指定するには以下のように書く：

```swift
let options: Set<Product.PurchaseOption> = [.quantity(selectedCount)] // selectedCount は View からの情報
let result = try await product.purchase(options: options)
```

個数指定は consumable か non-renewing subscription のみ行える。

Transaction からの購入個数の取得は `Transaction.purchasedQuantity` で行える。

<details><summary>現時点でのコード</summary><div>

**Store**

```swift
    func purchase(_ product: Product, options: Set<Product.PurchaseOption> = []) async throws {
        let result = try await product.purchase(options: options)
        
        switch result {
        case let .success(verificationResult):
            switch verificationResult {
            case let .verified(transaction):
                // 購入情報を更新する
                await refreshPurchasedProducts()

                // consumable については refreshPurchasedProducts() で更新できなかったのでここで更新
                // currentEntitlements の説明では更新できそうに思えたができなかった
                if case .consumable = transaction.productType {
                    let amount = UserDefaults.standard.integer(forKey: transaction.productID)
                    UserDefaults.standard.set(amount + transaction.purchasedQuantity, forKey: transaction.productID)
                }

                // 省略...
            }
        }
    }
```

**UI (ごちゃごちゃしたので二段にしました)**

```swift:ConsumableCellView
import SwiftUI
import StoreKit

struct ConsumableCellView: View {
    @EnvironmentObject var store: Store
    let product: Product
    @State private var showAlert = false
    @State private var errorMessage = ""
    @State private var selectedCount = 1
    @State private var isShowingAmountPicker = false
    @AppStorage var amount: Int

    init(product: Product) {
        self.product = product
        self._amount = AppStorage(wrappedValue: 0, product.id)
    }

    var body: some View {
        VStack(spacing: 10) {
            HStack(spacing: 2) {
                Text(product.displayName)

                Spacer()

                Text(product.description)
                    .font(.caption)

                Spacer()

                Text("所持数：\(amount)")
                    .font(.caption2)

            }

            HStack {

                Button {
                    isShowingAmountPicker.toggle()
                } label: {
                    Text("個数：\(selectedCount)")
                }
                Text("￥\(String(describing: product.price * Decimal(selectedCount)))")

                Button {
                    Task {
                        await purchase()
                    }
                } label: {
                    Text("購入")
                }.buttonStyle(BorderlessButtonStyle())
            }
        }
        .alert(errorMessage, isPresented: $showAlert) {
            Button("OK", role: .cancel) { }
        }
        .sheet(isPresented: $isShowingAmountPicker) {
            AmountPicker(selectedCount: $selectedCount, isShowingPicker: $isShowingAmountPicker)

        }
    }

    func purchase() async {

        let options: Set<Product.PurchaseOption> = [.quantity(selectedCount)]

        do {
            try await store.purchase(product, options: options)
        } catch {
            showAlert = true
            errorMessage = "購入に失敗しました"
        }
    }
}

struct AmountPicker: View {
    @Binding var selectedCount: Int
    @Binding var isShowingPicker: Bool

    var body: some View {
        Picker(selection: $selectedCount, label: Text("個数")) {
            ForEach(1 ..< 100) { num in
                Text("\(num)").tag(num)
            }
        }
        .pickerStyle(.wheel)
    }
}
```
</div></details>

<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/747207/826553d2-b882-2959-6c71-4455f65f7cae.gif width=300>

## Non-consumable の追加
Non-consumable の商品を追加してみる。

![Screen Shot 2022-08-14 at 9.19.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/747207/c189f5da-04d1-2229-7e79-b06dff298c47.png)

Consumable はアプリ側で購入情報を保持しなければいけなかったが、non-consumable や subscription は [最新の購入情報の取得](#最新の購入情報の取得) で紹介した `Transaction.currentEntitlements` で購入しているかが分かる。
なので、起動時に購入情報を取得する必要がある。

```swift:Store.swift
class Store: ObservableObject {

    @Published var consumableProductList: [Product] = []
    @Published var nonConsumableProductList: [Product] = []
    @Published var purchasedNonConsumableProductList: [Product] = []
    // 中略

    init() {
        Task {
            await fetchProducts()
            await refreshPurchasedProducts() // 購入情報の更新
        }
        // 中略
    }
    
    // 中略

    @MainActor
    func fetchProducts() async {
        do {
            consumableProductList = try await Product.products(for: ["consumable.apple"])
            nonConsumableProductList = try await Product.products(for: ["non.consumable.knife"])
        } catch {
            print("error")
        }
    }

    // 中略

    @MainActor
    func refreshPurchasedProducts() async {
        var purchasedNonConsumables: [Product] = []

        for await verificationResult in Transaction.currentEntitlements {
            guard case .verified(let transaction) = verificationResult else { continue }

            switch transaction.productType {
            case .consumable:
                // 中略
            case .nonConsumable:
                guard let product = nonConsumableProductList.first(where: { $0.id == transaction.productID }) else { continue }
                purchasedNonConsumables.append(product)
            default:
                continue
            }
        }

        purchasedNonConsumableProductList = purchasedNonConsumables
    }
}
```

購入の手順は consumable と同じ。

<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/747207/09cd176d-ced5-0af5-69f2-ba956718921c.gif width=300>

<details><summary>現時点でのコード</summary>

**View**

```swift:ContentView.swift
import SwiftUI

struct ContentView: View {

    @StateObject var store: Store = Store()

    var body: some View {
        if store.consumableProductList.isEmpty == false && store.nonConsumableProductList.isEmpty == false {

            List {
                Section("食品") {
                    ForEach(store.consumableProductList) { product in
                        ConsumableCellView(product: product)
                    }
                }
                .listStyle(GroupedListStyle())

                Section("食器") {
                    ForEach(store.nonConsumableProductList) { product in
                        NonConsumableCellView(product: product)
                    }
                }
                .listStyle(GroupedListStyle())
            }
            .environmentObject(store)
        }
    }
}
```

```swift:NonConsumableView.swift
import SwiftUI
import StoreKit

struct NonConsumableCellView: View {
    @EnvironmentObject var store: Store
    let product: Product
    @State private var showAlert = false
    @State private var errorMessage = ""
    @State private var isPurchased = false

    var body: some View {
        VStack(spacing: 10) {
            HStack(spacing: 2) {
                Text(product.displayName)

                Spacer()

                Text(product.description)
                    .font(.caption)

                Spacer()

                Text(isPurchased ? "購入済み" : "未購入")
                    .font(.caption)
            }

            HStack {
                Text("￥\(String(describing: product.price))")

                Button {
                    Task {
                        await purchase()
                    }
                } label: {
                    Text("購入")
                }
                .buttonStyle(BorderlessButtonStyle())
                .disabled(isPurchased)
            }
        }
        .alert(errorMessage, isPresented: $showAlert) {
            Button("OK", role: .cancel) { }
        }
        .onChange(of: store.purchasedNonConsumableProductList) { _ in
            Task {
                isPurchased = (try? await store.isPurchased(product)) ?? false
            }
        }
    }

    func purchase() async {

        do {
            try await store.purchase(product)
            isPurchased = try await store.isPurchased(product)
        } catch {
            showAlert = true
            errorMessage = "購入に失敗しました"
        }
    }
}
```

**Store**

```swift:Store.swift
import Foundation
import StoreKit

class Store: ObservableObject {

    @Published var consumableProductList: [Product] = []

    @Published var nonConsumableProductList: [Product] = []

    @Published var purchasedNonConsumableProductList: [Product] = []

    private var updates: Task<Void, Never>? = nil

    init() {
        Task {
            await fetchProducts()

            await refreshPurchasedProducts() // 購入情報の更新
        }

        updates = newTransactionListenerTask()
    }

    deinit {
        updates?.cancel()
    }

    private func newTransactionListenerTask() -> Task<Void, Never> {
        Task(priority: .background) {
            for await verificationResult in Transaction.updates {
                guard
                    case .verified(let transaction) = verificationResult,
                    transaction.revocationDate == nil
                else { continue }

                // 購入情報の更新
                await refreshPurchasedProducts()

                await transaction.finish()
            }
        }
    }

    @MainActor
    func fetchProducts() async {
        do {
            consumableProductList = try await Product.products(for: ["consumable.apple"])
            nonConsumableProductList = try await Product.products(for: ["non.consumable.knife"])
        } catch {
            print("error")
        }
    }

    func purchase(_ product: Product, options: Set<Product.PurchaseOption> = []) async throws {
        let result = try await product.purchase(options: options)

        switch result {
        case let .success(verificationResult):
            switch verificationResult {
            case let .verified(transaction):
                // 購入情報を更新する
                await refreshPurchasedProducts()

                // consumable については refreshPurchasedProducts() で更新できなかったのでここで更新
                // currentEntitlements の説明では更新できそうに思えたができなかった
                if case .consumable = transaction.productType {
                    let amount = UserDefaults.standard.integer(forKey: transaction.productID)
                    UserDefaults.standard.set(amount + transaction.purchasedQuantity, forKey: transaction.productID)
                }

                // transaction を終了させる必要がある
                await transaction.finish()

            case let .unverified(_, verificationError):
                // StoreKit の validation が通らなかった場合
                // 自分で validate することができる
                throw verificationError
            }
        case .pending:
            // ユーザーのアクションが必要
            // transaction が終わったら Transaction.updates から取れる
            break
        case .userCancelled:
            // ユーザーがキャンセルした
            break
        @unknown default:
            break
        }
    }

    @MainActor
    func refreshPurchasedProducts() async {
        var purchasedNonConsumables: [Product] = []

        for await verificationResult in Transaction.currentEntitlements {

            // 取り消しや払い戻された transaction は currentEntitlements には現れないので
            // transaction.revocationDate はチェックしなくて良い
            guard case .verified(let transaction) = verificationResult else { continue }

            switch transaction.productType {
            case .consumable:
                // transaction が終了していなかった consumable が来る
                let amount = UserDefaults.standard.integer(forKey: transaction.productID)
                UserDefaults.standard.set(amount + transaction.purchasedQuantity, forKey: transaction.productID)
            case .nonConsumable:
                guard let product = nonConsumableProductList.first(where: { $0.id == transaction.productID }) else { continue }
                purchasedNonConsumables.append(product)
            default:
                continue
            }
        }

        purchasedNonConsumableProductList = purchasedNonConsumables
    }

    func isPurchased(_ product: Product) async throws -> Bool {
        switch product.type {
        case .nonConsumable:
            return purchasedNonConsumableProductList.contains(product)
        default:
            return false
        }
    }
}
```
</details>

## ファミリー共有できる商品かを取得する

ファミリー共有できる商品かは `Product.isFamilyShareable` で取得できる。
例えば、View で以下のような分岐として使える

```swift
if product.isFamilyShareable {
    Text("ファミリー共有")
        .font(.caption)
        .foregroundColor(.green)
}
```

<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/747207/b9761540-47b3-225b-81cc-2b1d535db8f4.png width=300>

# 終わりに

StoreKit 2 での課金の基本について調べました。
再インストール時に `Transaction.currentEntitlements` の処理が正常に動いているかは TestFlight にあげないと確認できなさそうだったので残念です（WWDCの動画でも rebuild 時の確認だけしていました）

今後確認したいことは以下です。
- Promotional Offer での購入：[promotionalOffer(offerID:keyID:nonce:signature:timestamp:)](https://developer.apple.com/documentation/storekit/product/purchaseoption/3749444-promotionaloffer)
- Parental Control の機能をテストする：[simulatesAskToBuyInSandbox(_:)](https://developer.apple.com/documentation/storekit/product/purchaseoption/3749446-simulatesasktobuyinsandbox)
- 払い戻し画面の表示：[beginRefundRequest(in:)](https://developer.apple.com/documentation/storekit/transaction/3803220-beginrefundrequest)
- 現在購入中のサブスクリプションを確認するシートを出す：[showManageSubscriptions(in:)](https://developer.apple.com/documentation/storekit/appstore/3803198-showmanagesubscriptions)
- ユーザーが購入できるかどうかを示す：[canMakePayments](https://developer.apple.com/documentation/storekit/appstore/3822277-canmakepayments)
- 復元処理。自動で同期されるように実装すべきなので、普段は使わないが、何かあった時にユーザーが更新できるように用意しておくと良い：[sync()](https://developer.apple.com/documentation/storekit/appstore/3791906-sync)

# 参考リンク（本文に載せていないもの）
- [Implementing a store in your app using the StoreKit API | Apple Developer Documentation](https://developer.apple.com/documentation/storekit/in-app_purchase/implementing_a_store_in_your_app_using_the_storekit_api)
- [Meet StoreKit 2 - WWDC21](https://www.google.com/url?sa=t&rct=j&q=&esrc=s&source=web&cd=&ved=2ahUKEwim4Z2I4736AhXlnFYBHR22Cu0QwqsBegQIAhAB&url=https%3A%2F%2Fdeveloper.apple.com%2Fvideos%2Fplay%2Fwwdc2021%2F10114%2F&usg=AOvVaw3hgxrMydKFAYsRYdAw_gZz)