Alexa スキル内課金（ISP）の商品をSMAPIで操作する

SMAPIで操作したことがなかったので、いまさらながらスキル内課金をやってみた。

# スキル内課金とは

スキル内課金（ISP：In-Skill Purchasing）とは、カスタムスキル内でプレミアムコンテンツ（デジタルコンテンツ）を販売することができる仕組み。スキルの使用中に、商品を購入するイメージ。

# ISPの種類

３種類の課金商品が作成できる。

|No.|種類|説明|有効期限|
|:--|:--|:--|:--|
|1|買い切り型|１度購入したらずっと使える商品。|なし|
|2|消費型|定められた回数や期限内で使える商品。回数や期限が過ぎてしまったら再購入する必要がる。|回数や期限|
|3|サブスクリプション型|定められた間隔（期間）で自動購入される商品。キャンセルするまでの間、定期購読（継続購入）される。|サブスクリプションをキャンセルするまで|

# ISPの機能

３種類の機能が用意されている。

|No.|機能|説明|
|:--|:--|:--|
|1|Buy|商品を購入する|
|2|Upsell|商品を勧める|
|3|Cancel|商品をキャンセルする|

# ISPの仕組み

Skill Connections の仕組みが使われている。`Buy`、`Upsell` と `Cancel` はカスタムスキルではなくAlexa側で処理される。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/426739/b7334276-9228-5a47-793f-8555aab477f7.png)


# スキルにISPを追加する

購入やキャンセルに至るまでの通常の会話はインテントモデルにも基づいて作成する。いざ `購入やキャンセルをする！` となった場合にISPの仕組みを追加していく。

#### 商品を作成する

商品情報のJSONファイルを作成する。商品情報はJSONで作成し、ファイルとして保存しておく。保存する場所はコマンドラインから参照できるところであればどこでもよい。買い切り型、消費型、サブスクリプション型でJSONの書き方が異なるので、詳しくは:point_right:[スキーマ（※ここをクリック）](https://developer.amazon.com/en-US/docs/alexa/smapi/isp-schemas.html#schema-parts)を参照する。

```
{
  "version": "1.0",
  "type": "CONSUMABLE",
  "referenceName": "life_pack_10",
  "publishingInformation": {
    "locales": {
      "ja-JP": {
        "name": "ライフパック１０",
        "smallIconUri": "https://xxxx.com/life_pack_10_smallIconUri.png",
        "largeIconUri": "https://xxxx.com/life_pack_10_largeIconUri.png",
        "summary": "ライフパック１０サマリー。",
        "description": "ライフパック１０説明。",
        "examplePhrases": [
          "アレクサ、ライフパックを購入する",
          "アレクサ、ライフパックを買う"
        ],
        "keywords": [
          "ゲーム"
        ],
        "customProductPrompts": {
          "purchasePromptDescription": "ライフを追加するよ。",
          "boughtCardDescription": "買ってくれてありがとう。"
        }
      }
    },
    "distributionCountries": [
      "JP"
    ],
    "pricing": {
      "amazon.co.jp": {
        "releaseDate": "2022-02-27T01:25Z",
        "defaultPriceListing": {
          "price": 99,
          "currency": "JPY"
        }
      }
    },
    "taxInformation": {
      "category": "SOFTWARE"
    }
  },
  "privacyAndCompliance": {
    "locales": {
      "ja-JP": {
        "privacyPolicyUrl": "https://xxxx.com/privacy.txt"
      }
    }
  },
  "testingInstructions": "テストの説明",
  "purchasableState": "PURCHASABLE",
  "promotableState": "ALL_AMAZON_CHANNELS"
}
```


<font color="OrangeRed">商品を追加するためのリクエスト用JSONを作成する。</font>上記で作成したJSONファイルを使ってリクエスト用のJSONファイルを作成する。ベンダーIDが必要なんですね。

```json: product-request.json
{
  "vendorId": "MA32XWXRJKXIX",
  "inSkillProductDefinition": {

    ※買い切り型 / 消費型 / サブスクリプション型 のJSONスキーマを記載

  }
}
```


SMAPIを使って<font color="OrangeRed">商品を登録する。</font>上記で作成したリクエストJSONファイルを使ってスキル内商品を作成する。作成された商品は、ベンダー（ベンダーID）に登録されただけであって、この時点では、まだスキルとは紐づいていない。ただ商品をベンダーに登録しただけ。

```Shell
# 指定されたベンダーIDに対して新しいスキル内商品を作成する。
ask smapi create-isp-for-vendor --create-in-skill-product-request file:./product_definition.json

# プロダクトIDが返却される
{
  "productId": "amzn1.adg.product.e0db647a-9dcf-4ac3-b382-b5e953bb42e3"
}
```

#### 商品をスキルに紐づける

上記で返却されたプロダクトIDを使って対象スキルに紐づける。

```
# スキルとスキル内商品を関連付ける。
ask smapi associate-isp-with-skill --product-id amzn1.adg.product.e0db647a-9dcf-4ac3-b382-b5e953bb42e3 --skill-id amzn1.ask.skill.f837e40e-d552-4041-8d34-bbdbec9bf08b

# 下記が表示されればＯＫ
Command executed successfully!
```

# Lambdaの処理を追加する

#### バックエンドコードにディレクティブを追加する

`Buy`、`Upsell` と `Cancel` がある。`Upsell` は商品JSONに含まれる `purchasePromptDescription` を発話してくれる。
購入に至るまでの流れは、通常のスキル開発と同じでインテントとアレクサレスポンスでやりとりをする。そして購入するぞ！となったときに、ディレクティブを送信することでAlexa側の購入フローに遷移してくれる。

ディレクティブを送信するとセッションが切断される。そのため必要なデータはディレクティブを呼ぶ前に永続ストレージ（DynamoDBやS3）に保存しておく。

```javascript: 例）buy：購入
  return handlerInput.responseBuilder
    .addDirective({
      type: 'Connections.SendRequest',
      name: 'Buy',
      payload: {
        InSkillProduct: {
          productId: 'amzn1.adg.product.e0db647a-9dcf-4ac3-b382-b5e953bb42e3',
        },
      },
      token: 'correlationToken',
    })
    .getResponse();
```

#### スキルコネクションズのレスポンスを処理する

スキルコネクションズからのレスポンスは `request.type` と `request.name` で判別します。そして、`payload.purchaseResult` の内容によって、それに相応する処理を実装します。


```javascript: 例）購入フローからの戻りを受け取る
const BuyResponseHandler = {
  canHandle(handlerInput) {
    return handlerInput.requestEnvelope.request.type === 'Connections.Response' &&
      (handlerInput.requestEnvelope.request.name === 'Buy' ||
        handlerInput.requestEnvelope.request.name === 'Upsell');
  },
  async handle(handlerInput) {
    const locale = handlerInput.requestEnvelope.request.locale;
    const ms = handlerInput.serviceClientFactory.getMonetizationServiceClient();
    const productId = handlerInput.requestEnvelope.request.payload.productId;

    return ms.getInSkillProducts(locale).then(function handlePurchaseResponse(result) {
      const product = result.inSkillProducts.filter(record => record.productId === productId);

      if (handlerInput.requestEnvelope.request.status.code === '200') {
        switch (handlerInput.requestEnvelope.request.payload.purchaseResult) {
          case 'ACCEPTED': 

            // 購入した場合の処理、発話を組み立てる
            break;
          case 'DECLINED':

            // 購入しなかった場合の処理、発話を組み立てる
            break;
          case 'ALREADY_PURCHASED':

            // 既にその商品を購入している場合の処理、発話を組み立てる
            break;
          default:
            speakOutput = '購入できませんでした。音声ショッピングの設定やお支払い方法をご確認ください。';
            break;

        return handlerInput.responseBuilder
          .speak(speakOutput)
          .reprompt(repromptOutput)
          .getResponse();
      }
      // 処理中にエラーが発生した場合
      console.log(`Connections.Response indicated failure. error: ${handlerInput.requestEnvelope.request.status.message}`);

      return handlerInput.responseBuilder
        .speak('購入処理でエラーが発生しました。もう一度試すか、カスタマーサービスにご連絡ください。')
        .getResponse();
    });
  },
};
```

# テストと申請の観点

観点が公開されているので、これ等を全てクリアする。

- テストの観点
[スキル内課金スキルのテスト](https://developer.amazon.com/ja-JP/docs/alexa/in-skill-purchase/isp-test-guide.html)を参照し、観点を漏れなくテストする。 
- スキル申請時の観点
[スキル内課金の認定](https://developer.amazon.com/ja-JP/docs/alexa/in-skill-purchase/isp-certification-guide.html)に、認定時に行われる内容が記載されている。


# 参考：ISPを操作するためのSMAPIコマンド

詳細は、[ASK CLIコマンドリファレンス](https://developer.amazon.com/ja-JP/docs/alexa/smapi/ask-cli-command-reference.html)を参照。

```shell
# ==== 商品確認 ====
# Amazonアカウントに関連付けられたベンダーIDに紐づいている商品のリストを表示する。
ask smapi get-isp-list-for-vendor

# スキルに紐づいている商品の一覧を表示する。
ask smapi get-isp-list-for-skill-id --skill-id amzn1.ask.skill.45f6f820-5e75-43f4-994a-b560c6a8803c

# 更新された商品情報を表示する
$ ask smapi get-isp-summary --product-id amzn1.adg.product.9333d53e-d66d-44d3-9ab6-1c73b1f6dee8 --stage development


# ==== 商品作成 ====
# 指定されたベンダーIDに対して新しいスキル内商品を作成する。
ask smapi create-isp-for-vendor --create-in-skill-product-request file:./product_definition.json

# プロダクトIDが返却される。
{
  "productId": "amzn1.adg.product.e0db647a-9dcf-4ac3-b382-b5e953bb42e3"
}

# スキルとスキル内商品を関連付ける。
ask smapi associate-isp-with-skill --product-id  amzn1.adg.product.9333d53e-d66d-44d3-9ab6-1c73b1f6dee8 --skill-id amzn1.ask.skill.45f6f820-5e75-43f4-994a-b560c6a8803c

# 下記が表示されればＯＫ
Command executed successfully!


# ==== 商品情報更新 ====
# Update the product definition
$ ask smapi update-isp-for-product --product-id amzn1.adg.product.9333d53e-d66d-44d3-9ab6-1c73b1f6dee8 --in-skill-product file:./treasure_finder.json --stage development


# ==== 商品削除 ====
# "productId"のスキル内商品を削除する。
# ※削除対象のスキル内商品が、まだスキルと紐づいているままの場合は商品を削除できない。
ask smapi delete-isp-for-product --product-id amzn1.adg.product.9333d53e-d66d-44d3-9ab6-1c73b1f6dee8 --stage development

# 下記が表示されればＯＫ
Command executed successfully! 

# disassociate-isp-with-skill を使用してリンクを解除する。
ask smapi disassociate-isp-with-skill --product-id amzn1.adg.product.71eff630-b77b-4a99-9020-d62b118493d7 --skill-id amzn1.ask.skill.b130557b-0306-4512-ad1e-65a0d4023089

# 下記が表示されればＯＫ
Command executed successfully!


# ==== スキル内商品がどのスキルで使われているかを確認する ====
# 商品が紐づいているスキルを表示する
ask smapi get-isp-associated-skills --product-id amzn1.adg.product.d3889d23-8273-4d88-9781-55b1ae5ab279 --stage development

# スキル情報の取得
ask smapi list-skills-for-vendor --skill-id amzn1.ask.skill.b130557b-0306-4512-ad1e-65a0d4023089

```