Alexa スキル内課金　第2回　スキル内商品の購入処理

#はじめに
前回はスキル内課金の仕組みと、**スキル内商品**レコードの作成と取得について説明しました。
今回は、Alexaによるスキル内商品の購入処理について説明します。

#今回実施する内容
**スキル内商品**の購入の要求と、購入実施結果後の処理を作成します。
また、レコメンド、キャンセル処理についても説明します。

#環境
OS:Windows 10 JP
Alexaスキル言語：Node.js
Editor：Visual Studio Code
Alexa developer console

#参考
・[Alexaスキルを使用した商品の販売](https://developer.amazon.com/ja/docs/custom-skills/sell-products-in-an-alexa-skill.html)
Alexzオフィシャルドキュメントの説明です。

・[AlexaサービスAPIの呼び出し](https://ask-sdk-for-nodejs.readthedocs.io/ja/latest/Calling-Alexa-Service-APIs.html)
Node.jsでスキル内課金サービスのAPIへアクセスする説明です。

・[Alexaデザインガイド スキル内課金](https://developer.amazon.com/ja/docs/alexa-design/isp.html)
スキル内課金のデザインのベストプラクティスなどの説明です。

・[Alexa スキル内課金　第1回　スキル内課金の仕組みとスキル内商品レコード作成、読み込み](https://qiita.com/Mount/items/6710a7b897951273f780)
スキル内課金の仕組みの概要の説明です。

#用語
##スキル内課金 (ISP)
スキルの中の課金の仕組みこと。
In-skill Purchasing

#前提条件
[Alexa スキル内課金　第1回　スキル内課金の仕組みとスキル内商品レコード作成、読み込み](https://qiita.com/Mount/items/6710a7b897951273f780)の記事を読んでいる。

#Alexaの購入処理
##Alexaによる購入処理の概要

- ユーザーが購入の意思をスキルに伝えると、
- スキルは、Alexaに購入処理を委任(Connections.SednRequest)します。
  スキル内商品のレコメンド方法などのお作法は、[Alexaデザインガイド スキル内課金](https://developer.amazon.com/ja/docs/alexa-design/isp.html)にありますが、それは割愛して仕組みだけ説明します。
- 購入処理が終わるとAlexaはスキルに応答(Connections.Response)します。
- その後はスキル内商品の利用を開始します。

![シーケンスフロー.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/405480/465c5519-4b60-5fb1-8d34-bbe49e040c28.jpeg)


##スキル内商品の購入試験をする前に注意事項
スキル内商品を購入するには、Alexaによる音声ショッピングを許可する必要があります。

iphoneでは、Alexaアプリを開いて
メニュー「設定」=>「Alexaアカウント」=>「音声ショッピング」で、「音声による商品の注文」をONにすることで許可できます。
自分で作成した開発中のスキルであれば、TESTの状態になっていますので、いくらでも購入しても課金は発生しませんが、**試験以外のスキルで、実際に商品の購入が本当にできてしまいますので、ご注意ください**。
スマホはもっていないのでわかりませんが、似たようなメニュー操作とは思います。

また、スキル内商品のテストは、iphoneのAlexaアプリでは実行できませんでした。PCによるAlexaシミュレーターでは試験できました。

##Node.jsによるAlexaへの購入処理委任
Alexaへ購入処理を委任するには、`.addDirective`に`Connections.SendRequest`と`Buy`を設定してreturnを返します。

```js
return handlerInput.responseBuilder
          .addDirective({
              type: 'Connections.SendRequest',
              name: 'Buy',
              payload: {
                  InSkillProduct: {
                      productId: product[0].productId,
                  },
              },
              token: 'token',
          })
          .getResponse();
```

`name`は、**`Buy`**、**`Upsell`**、**`Cancel`**はどれかを選択します。
**`Buy`**は購入を委任する時に使用します。
**`Upsell`**は、商品の購入時のレコメンドに使用します。
**`Cancel`**は、購入した商品の取り消し時に使用します。
今回は購入なため、`Buy`です。

`payload`には、スキル内商品のproductIdを設定します。このproductIdは、[第1回の説明](https://qiita.com/Mount/items/6710a7b897951273f780)の時に出てきた**スキル内商品**レコードに登録されたproductIdです。

`token`は、トークンを識別するだけなので、なんでもよいと思います。

ということで、このHandlerは以下の通りです。

```js:BuyISPReqIntentHandler
const BuyISPReqIntentHandler = {
  canHandle(handlerInput) {
    return handlerInput.requestEnvelope.request.type === 'IntentRequest'
       &&  handlerInput.requestEnvelope.request.intent.name === 'BuyISPReqIntent'
  },
  handle(handlerInput) {
    console.log("BuyISPReqIntentHandler");
    const locale = handlerInput.requestEnvelope.request.locale;
    const ms = handlerInput.serviceClientFactory.getMonetizationServiceClient();
    return ms.getInSkillProducts(locale).then((result) => {
      // 購入する商品データを抽出
      const product = result.inSkillProducts.filter(record => record.referenceName === "課金商品");
      // ** ディレクティブにBuyを送信し購入フローに進む ** 
      return handlerInput.responseBuilder
        .addDirective({
          type: 'Connections.SendRequest',
          name: 'Buy',
          payload: {
            InSkillProduct: {
              productId: product[0].productId,
            },
          },
          token: 'token',
        })
        .getResponse();
    });
  },
};
```

##Node.jsによるAlexaへの購入処理後の処理
Alexaとユーザーの間で購入処理が終了すると、Alexaはスキルに応答(Connections.Response)を返します。これを受け取って、処理を実行します。

```js:BuyUpsellResponseHandler
const BuyUpsellResponseHandler = {
  canHandle(handlerInput) {
    return handlerInput.requestEnvelope.request.type === 'Connections.Response' &&
      (handlerInput.requestEnvelope.request.name === 'Buy' ||
       handlerInput.requestEnvelope.request.name === 'Upsell');
    },
```

上記の通り、通常Intentを受ける`request.type`は、**`Connections.Response`**になります。
`request.name`は、**`Buy`**です。ついでに`Upsell`も追加しています。
これで購入処理結果を受け取れますので、その後handleで処理を記載します。

```js:BuyUpsellResponseHandler
  handle(handlerInput) {
    console.log(`BuyUpsellResponseHandler`);
    const locale = handlerInput.requestEnvelope.request.locale;
    const ms = handlerInput.serviceClientFactory.getMonetizationServiceClient();
    const productId = handlerInput.requestEnvelope.request.payload.productId;

    return ms.getInSkillProducts(locale).then((result) => {
      if (handlerInput.requestEnvelope.request.status.code === '200') {
        let speakOutput;
        let repromptOutput;
        switch (handlerInput.requestEnvelope.request.payload.purchaseResult) {
          case 'ACCEPTED':
            // 購入した
            speakOutput = `購入しました。`;
            repromptOutput = `購入しました。`;
            break;
          case 'DECLINED':
            // 購入しなかった
            if (handlerInput.requestEnvelope.request.name === 'Buy') {
              speakOutput = `また購入検討してくださいね。`;
              repromptOutput = `また購入検討してくださいね。`;
              break;
            }  
            speakOutput = `またレコメンドしますね。`;
            repromptOutput = `またレコメンドしますね。`;            
            break;
          case 'ALREADY_PURCHASED':
            // 購入済みだった
            speakOutput = `購入済みでした。`;
            repromptOutput = `購入済みでした。`;
            break;
          default:
            // 何らかの理由で購入に失敗した場合。
            speakOutput = `購入できませんでした。音声ショッピングの設定やお支払い方法をご確認ください。`;
            repromptOutput = `購入できませんでした。音声ショッピングの設定やお支払い方法をご確認ください。`;
            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();
    });
  },
};
```

長いですが、そんなにむずかしいことはしていません。
`      if (handlerInput.requestEnvelope.request.status.code === '200') {`は、
Connections.Responseが正常であれば、200が返ってきます。
その後、
`        switch (handlerInput.requestEnvelope.request.payload.purchaseResult) {`で、購入結果を取得して分岐します。
`case 'ACCEPTED':`は、今回のケースでは購入したことを示します。
`case 'DECLINED':`は、今回のケースでは購入しなかったことを示します。
その後に、`name`の`Buy`でif文を作っていますが、Buyではなく、Upsellだった場合は、レコメンドになるため、if文としました。
`case 'ALREADY_PURCHASED':`は、購入済みだった場合の処理です。おそらく通常は、ここに来ないように、事前に**スキル内商品**レコードの購入情報をみて、Alexaに購入処理を委任しないようにすべきと思います。

##Buyの動作検証
上記で作成したスキルで試験してみます。

1. 「BuyISPReqIntentHandler」を起こす会話を実施する。
2. Alexaは、「購入プロンプトの説明です。 プライム会員には、￥20の割引が適用されます。プライム会員ではない場合は、税込価格￥99です。利用規約や商品の詳細はAlexaアプリのホームカードで確認できます。同意して、購入しますか？」と答える。
3. 「はい」と言う。
4. Alexaは「はい、課金商品の購入処理が完了しました。」と答える。
5. Alexaは、「購入しました。」と答える。repromptも設定したため、再送もあります。

ここで、1と5はスキルによる動作です。
2~4は、購入処理をAlexaが実施したときの会話です。
なお、「購入プロンプトの説明です。」というセリフは、**スキル内商品**レコード登録時に記載した「購入プロンプトの説明」の値のようです。

こうみると、Alexaは**スキル内商品**レコード自体は使っていないように見受けます。そのうち利用されるのかなとは思いますが、どうなんでしょうか。

##Node.jsによるAlexaへのレコメンド処理委任
`.addDirective`に`Connections.SendRequest`を設定して、`name`に`Upsell`を設定します。
まずは、フローです。
![シーケンスフロー2.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/405480/725f1072-fcb4-a577-57b6-94f3e5eb1b12.jpeg)

フローの通り、Upsellは、Buy処理の前の会話で、Upsellで購入意思を見せる（「はい」）と、購入処理に進み、「いいえ」というと、レコメンドが終了します。
現状のところ、Upsellの動作は、いまいちかなと感じました。
なぜかといえば、Alexaが実施する「レコメンド」が、

- 実は、`.addDirective`内で設定する`upsellMessage`を読み上げているだけ。
- それに対するユーザーからの応答は、「はい」、または、「いいえ」しか受け付けないように見える。

ためで、この程度であれば、自分でスキルに処理を書いてもさしてメリットがないなと思ったのです。
このあたりの実装はこれからかなと思います。
せっかく、**スキル内商品**レコードに情報を記載したのだから、それをもう少し使ってほしいなと思います。
ソースコードは以下です。

```js:RecommendISPReqIntentHandler
const RecommendISPReqIntentHandler = {
  canHandle(handlerInput) {
    return handlerInput.requestEnvelope.request.type === 'IntentRequest'
       &&  handlerInput.requestEnvelope.request.intent.name === 'RecommendISPReqIntent'
  },
  handle(handlerInput) {
    console.log("RecommendISPReqIntentHandler");
    const locale = handlerInput.requestEnvelope.request.locale;
    const ms = handlerInput.serviceClientFactory.getMonetizationServiceClient();
    return ms.getInSkillProducts(locale).then((result) => {
      // レコメンドする商品データを抽出
      const product = result.inSkillProducts.filter(record => record.referenceName === "課金商品");
      // ** ディレクティブに送信し購入フローに進む ** 
      return handlerInput.responseBuilder
        .addDirective({
          type: 'Connections.SendRequest',
          name: 'Upsell',
          payload: {
            InSkillProduct: {
              productId: product[0].productId,
            },
            upsellMessage: product[0].summary
          },
          token: 'token',
        })
        .getResponse();
    });
  },
};
```

Buyとの違いは、`name`が`Upsell`になったことと、`payload`に`upsellMessage`が追加されたことです。

##Upsellの動作検証
上記で作成したスキルで試験してみます。

1. 「RecommendISPReqIntentHandler」を起こす会話を実施する。
2. Alexaは、「説明です。」と答える。
3. 「はい」と言う。
4. Alexaは、「購入プロンプトの説明です。 プライム会員には、￥20の割引が適用されます。プライム会員ではない場合は、税込価格￥99です。利用規約や商品の詳細はAlexaアプリのホームカードで確認できます。同意して、購入しますか？」と答える。
5. 「はい」と言う。
6. Alexaは「はい、課金商品の購入処理が完了しました。」と答える。
7. Alexaは、「購入しました。」と答える。repromptも設定したため、再送もあります。

なんともかみ合わない会話になっていますが、現状レコメンドに対して、「はい」、または、「いいえ」で応答するしかないようなので、「upsellMessage」には、「はい」、または、「いいえ」と答えさせる文章を設定しておく必要があるようです。

BuyUpsellResponseHandler内の動作に関し、最初にUpsellでリクエストしても、途中で購入処理に切り替わった場合、Alexaからの`Connections.Response`の`handlerInput.requestEnvelope.request.name`は、`'Buy'`で戻ってくるようですので、注意が必要です。そのため、処理では、この部分をif文でわけていました。

##Node.jsによるAlexaへの購入後のキャンセル処理委任
Alexaへスキル内商品購入後ののキャンセル処理を委任するには、`.addDirective`に`Connections.SendRequest`を設定して、`name`に`Cancel`を設定します。

```js
const CancelISPReqIntentHandler = {
  canHandle(handlerInput) {
    return handlerInput.requestEnvelope.request.type === 'IntentRequest'
      && handlerInput.requestEnvelope.request.intent.name === 'CancelISPReqIntent';
  },
  handle(handlerInput) {
    console.log(`CancelISPReqIntentHandler`);
    const locale = handlerInput.requestEnvelope.request.locale;
    const ms = handlerInput.serviceClientFactory.getMonetizationServiceClient();

    return ms.getInSkillProducts(locale).then(function initiateCancel(result) {
      // 購入する商品データを抽出
      const product = result.inSkillProducts.filter(record => record.referenceName === "課金商品");

      // Cancelディレクティブを送信
      return handlerInput.responseBuilder
        .addDirective({
          type: 'Connections.SendRequest',
          name: 'Cancel',
          payload: {
            InSkillProduct: {
              productId: product[0].productId,
            },
          },
          token: token,
        })
        .getResponse();
    });
  },
};
```

購入時と実施内容はほとんど同じで、`name`が`Cancel`になっただけです。
実は、キャンセル処理と言っても、実際には返金方法をスマホのアプリに送るだけのようです。
で、そこからカスタマーサービスへチャットするか電話するかの流れになるようです。
購入はすぐにできても、返金は少し面倒です。

##Node.jsによるAlexaへの購入後のキャンセル処理後の処理

Alexaとユーザーの間でキャンセル処理が終了するとAlexaは、スキルに応答(Connections.Response)を返します。これを受け取って、処理を実行します。

```js:CancelResponseHandler
const CancelResponseHandler = {
  canHandle(handlerInput) {
    return handlerInput.requestEnvelope.request.type === 'Connections.Response'
        && handlerInput.requestEnvelope.request.name === 'Cancel';
  },
  handle(handlerInput) {
    console.log(`CancelResponseHandler`);
    const speechText = "キャンセル"
    return handlerInput.responseBuilder
      .speak(speechText)
      .reprompt(speechText)
      .withSimpleCard("スキル内課金", speechText)
      .getResponse();
  },
};
```

特に説明することはありません。
`request.name`が`Cancel`になっただけです。

##Cancelの動作検証
上記で作成したスキルで試験してみます。
まず事前に購入しておく必要があります。

1. 「CancelResponseHandler」を起こす会話を実施する。
2. Alexaは、「返金については、Alexaアプリにリンクを送信しましたので、そちらで確認してください。」と答える。
3. Alexaは「キャンセル」と答える。

Alexaが実施しているのは、2の内容で1,3はスキルによる応答です。

##試験時の購入情報のリセット
スキルの試験中に商品を購入した場合、購入をリセットすることが、Alexa Developer Consoleから実施でき、スキル内商品の画面で、リンクした商品の欄にある該当のスキル内商品の「テスト購入をリセット」を押せば、リセットできます。これがないと開発に困りますよね。
![reset.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/405480/cb305628-b004-48fa-e771-8db5c599d805.jpeg)

#おわりに
今回は、スキル内商品のレコメンド、購入、キャンセルの動作について説明しました。
次回は、購入したスキルの使い方について説明しようかと思います。

今回記載をしませんでしたが、購入済みの商品を購入しようとしたり、未購入のものをキャンセルしようとしたりすると、Alexaはそれはできませんと答えてくれます。このあたりの動作は委任できてありがたいところかなと思いました。