初めてのStripe: 完全にサーバーレスのチケット販売

本ブログは2021年[Stripe Advent Calendar 2021](https://qiita.com/advent-calendar/2021/stripe)の12月3日分のエントリーです。元々[dev.to](https://dev.to/aws-builders/chu-metenostripe-wan-quan-nisabaresunotiketutofan-mai-2780)に投稿されましたが、2022年11月14日にQiitaにクロスポストの移行・再投稿いたしました。

[ This post is available [in English here](https://dev.to/aws-builders/first-time-with-stripe-fully-serverless-ticket-sales-329a). ]

---

皆さん、こんにちは！Tokyo Demo Fest実行委員のテッダー　マイケルです。AWSとの9年間の開発経験を活かしながら、JAWS-UG札幌支部とJAWS-UGのコミュニティイベント（[FESTA 2019](https://jft2019.jaws-ug.jp/)、[DAYS 2021](https://jawsdays2021.jaws-ug.jp/)、[PANKRATION 2021](https://jawspankration2021.jaws-ug.jp/)）の運営を手伝いしています。2020年にAWSコミュニティビルダーに認定されました。

今回はTokyo Demo Fest 2021のチケット販売のため、AWS上で初めてStripeを実装した話を詳しくご紹介します。Stripeをまだ触っていない方でもわかりやすく伝えたいと思います。サンプルコードの言語はNode.js 14.xになります。

## Tokyo Demo Festについて

Tokyo Demo Fest (略: TDF)は日本で唯一のデモパーティです。デモパーティは、コンピュータを用いたプログラミングとアートに興味のある人々が日本のみならず、世界中から一堂に会し、デモ作品のコンペティション(コンポ)やセミナーなどを行います。また、イベント開催中は集まった様々な人たちとの交流が深められます。

デモについての解説は[こちらをご参照ください](https://tokyodemofest.jp/2016/?lang=ja#aboutdemo)。

過去のTDFのチケット販売はPayPalで行いましたが、今年からはようやくStripeに移行することができました。今回は実装がとても簡単なStripe Checkoutを利用し、実際のコーディングは数時間程度で対応できました。

## システム設計

こちらが全体図です。今回はStripeに限る話だけなので、ライブ配信周りなどは描かれてません。Stripeとその関係するシステムのみになります。

![Serverless Stripe Diagram](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2282314/bd65a285-5e0a-a97c-1fd5-c1906a9260b6.png)

Visitorは最初にAmplifyで公開されている[TDFのWebサイト](https://tokyodemofest.jp/index_ja.html)から入ります。2種類のチケットがあるのでどちらかを選択し、Stripe Checkoutへ移行します。決済完了時はStripeからのWebhookが呼び出され、チケット情報とVotekeyがStripeでの購入時に入力されたメールアドレスに送信されます。Visitorはメールで配布されたVotekeyを使い、[Wuhuパーティシステム](https://github.com/Gargaj/wuhu) (ECS/Fargate)にログインできるようになります。

## Stripe商品の作成

今回のTDFではチケットを2種類販売しています。

1. Visitor Ticket（1,000円）
2. Bronze Supporter (10,000円、Tシャツ無料配送込み)

Visitor TicketはStripeでは1つの「商品」になるので、簡単ですね。

Bronze SupporterはVisitor Ticketと金額が違うため別の商品が必要ですが、Tシャツのサイズ（S/M/L/XL）の選択もあるので、サイズ別の4つの商品を作成しました。Tシャツはチケット代に含まれているので、それぞれのサイズの金額は「ゼロ円」に設定しています。そしてTシャツは配送になるため、購入時にVisitorの住所入力が必要です。

![TDF Stripe Products](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2282314/51a2ab6f-8583-741f-6fa7-5f4ce30e482b.png)

Stripeで配送情報を入力させるためには「配送料金」を作成する必要があります。今回はTシャツの配送料もチケット代に含まれているため、こちらの金額も「ゼロ円」に設定しています。

![TDF Stripe Shipping](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2282314/f04c67db-0992-e81f-c386-e14d0575fc5b.png)

## Stripe APIキーのセキュリティ

Stripe APIキーは秘密情報なので、ソースコードに書き込んだりすると情報漏えいの要因になります。私のいつもの実装パターンですが、Lambdaを使ってる時はAWS Systems Managerの[Parameter Store](https://docs.aws.amazon.com/ja_jp/systems-manager/latest/userguide/systems-manager-parameter-store.html)にAPIキー等を保存することにしています。

APIキー、Webhookシークレット、商品のPriceID、配送料金IDなどは1つずつ別々のSecureStringに保存が可能ですが、実はStandardでも4KBまで保存が可能なので、すべての必要な情報をJSON化にし、1つの文字列として保存するのがとても楽です。

以下、キーの内容は隠してますが、実際保存してるデータがこんな感じです。

```
{"stripe_api_secret_key":"sk_test_51JU2XXXXXXXXXXXX",
"webhook_signing_secret":"whsec_TqW4TXXXXXXXXXXXX",
"product_visitor_ticket":"price_1JX1zXXXXXXXXXXXX",
"product_bronze_ticket":"price_1JrKaXXXXXXXXXXXX",
"product_tshirt_s":"price_1JrKlXXXXXXXXXXXX",
"product_tshirt_m":"price_1JrKmXXXXXXXXXXXX",
"product_tshirt_l":"price_1JrKnXXXXXXXXXXXX",
"product_tshirt_xl":"price_1JrKoXXXXXXXXXXXX",
"success_url":"https://tokyodemofest.jp/success.html",
"cancel_url":"https://tokyodemofest.jp#registration",
"shipping_rate":"shr_1JrKNXXXXXXXXXXXX",
"shipping_countries":"US,JP,IE,GB,NO,SE,FI,RU,PT,ES,FR,DE,CH,IT,PL,CZ,AT,HU,BA,BY,UA,RO,BG,GR,AU,NZ,KR,TW,IS"}
```

Lambdaが実行されてる際、このJSONをパラメータストアから読み込むためには Lambdaに内蔵されてる `aws-sdk` を使います。Stripeの初期化にはAPIキーを渡すのが必要なので、SSMからconfigを引っ張ってから初期化を行います。

```
const loadConfig = async function() {
  const aws = require('aws-sdk');
  const ssm = new aws.SSM();
  const res = await ssm.getParameter( { Name: '/tdf/config-stripe', WithDecryption: true } ).promise();
  return JSON.parse(res.Parameter.Value);
}

exports.handler = async (event) => {
  const config = await loadConfig();
  const stripe = require('stripe')(config.stripe_api_secret_key);
  // ...
}
```

## HTML側の購入ボタン作成

参考のため、今回のTDFのHTML側の購入ボタンを軽く紹介します。

![TDF Visitor Ticket](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2282314/d190ccc8-88ba-f969-ffee-a186db6cd96c.png)
![TDF Bronze Supporter](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2282314/4d8139b7-de28-547a-c1ca-7d22e3e4c490.png)

チケットの種類が2つなのですが、実はPOSTするエンドポイントが同じです。

![TDF Ticket HTML](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2282314/01ca7d3f-8915-76c8-75d6-d11384ad7571.png)

Lambda側でチケットの違いをわかるためには `<input type="hidden" name="type" value="bronze">` で判断しています。そして `type=bronze` の場合はTシャツサイズの `tshirt` 指定もわかります。

Stripe CheckoutにBronze SupporterチケットとTシャツの商品指定は次のセクションで紹介します。

## Stripe CheckoutへのURL生成

次はWebサイトでVisitorがチケット購入ボタンを押したら、Stripe Checkoutに移行させます。このURLには、どの商品を購入するとか、購入時に必要な情報（住所の入力が必要かどうか）が含まれています。URL生成はStripe SDKが行うので、URLが作成されたら、ブラウザに `HTTP 303` (See Other) で転送されると、Stripe Checkoutのページが表示されます。

LambdaでCheckoutセッションのURLを生成し転送させるにはこんな感じです。

```
exports.handler = async (event) => {
  const config = await loadConfig();
  const stripe = require('stripe')(config.stripe_api_secret_key);

  const session = await stripe.checkout.sessions.create( {
    line_items: /* TODO */,
    payment_method_types: [ 'card' ],
    mode: 'payment',
    success_url: config.success_url,
    cancel_url: config.cancel_url
  } );

  const response = {
    statusCode: 303,
    headers: {
      'Location': session.url
    }
  };

  return response;
}
```

セッションデータの `line_items` は商品を指定します。ブラウザからPOSTで送信されたデータがあるかどうかを確認し、 `line_items` に入れる商品データを変えます。なお、LambdaではペイロードがBase64エンコードされてることがあるので、デコードを行う必要があります。

```
  if (event.body) {
    let payload = event.body;
    if (event.isBase64Encoded)
      payload = Buffer.from(event.body, 'base64').toString();

    const querystring = require('querystring');
    const res = querystring.parse(payload);
    if ((res.type) && (res.type == 'bronze')) {
      // ...
    }
  }
```

まずVisitorチケットの場合は単純に1つの商品になります。

```
  let items = [ {
    price: config.product_visitor_ticket,
    quantity: 1
  } ];
```

Visitorチケットの1つの商品でStripe Checkoutに転送するとこんな感じで表示されます。

![Stripe Checkout Visitor](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2282314/10663781-329a-f2fd-a618-d09bc249bc21.png)

では、次にBronze Supporterチケットの場合は選択されたTシャツサイズを商品のPriceIDとマッチングし、2つの商品（チケットの商品とTシャツの商品）を `line_items` に入れます。

```
  let tshirt_type = config.product_tshirt_s;

  if (res.tshirt) {
    if (res.tshirt == 's') tshirt_type = config.product_tshirt_s;
    if (res.tshirt == 'm') tshirt_type = config.product_tshirt_m;
    if (res.tshirt == 'l') tshirt_type = config.product_tshirt_l;
    if (res.tshirt == 'xl') tshirt_type = config.product_tshirt_xl;
  }

  items = [ {
    price: config.product_bronze_ticket,
    quantity: 1
  }, {
    price: tshirt_type,
    quantity: 1
  } ];
```

あとは、Bronze Supporterチケットの場合はTシャツの配送に住所を入力してもらう必要があります。Checkoutセッションデータに配送料金の `shipping_rates` と配送対象国（どの国への配送が可能）を `shipping_address_collection` の `allowed_countries` で指定します。

まとめるとこんな感じになります。

```
  const session = await stripe.checkout.sessions.create( {
    line_items: [ {
      price: config.product_bronze_ticket,
      quantity: 1
    }, {
      price: tshirt_type,
      quantity: 1
    } ],
    payment_method_types: [ 'card' ],
    mode: 'payment',
    success_url: config.success_url,
    cancel_url: config.cancel_url,
    shipping_rates: [ config.shipping_rate ],
    shipping_address_collection: {
      allowed_countries: config.shipping_countries.split(',')
    }
  } );
```

配送情報がセッションデータに含まれるとStripe Checkoutで住所入力フィールドが一緒にフォームに表示されます。

![Stripe Checkout Bronze](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2282314/a3181570-f333-5faa-207b-5404e3c06dfc.png)

一応、この数十行のコードだけでStripe Checkoutでの決済は可能になりました。購入決済が完了されたら、StripeからWebhookを呼び出し、メール送信などの他の処理が可能なので、次のセクションで紹介します。

## Stripe Webhookの実装

先ほど紹介しましたが、TDFでは、チケット購入の決済完了となった時はVisitorへのチケット情報をメールで送信します。こちらの対応は別のLambda関数を作成し、API GatewayのURLをStripe Webhookに設定します。

![TDF Stripe Webhook](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2282314/03de9310-aa44-cd0e-1608-13621cbf51fd.png)

Webhookの処理は各自それぞれ違う対応になりますので、StripeからのPOSTデータのデコードまで紹介します。

まずは、WebhookのURLが公開されているため、誰でもアクセスができてしまいます。Stripeからのアクセスの際はHTTPヘッダーに署名が含まれ、Webhookシークレットのキーでペイロードデータが正常なのかを確認します。

```
exports.handler = async (event) => {
  // require Stripe signature in header
  if (!event.headers['stripe-signature']) {
    console.log('no Stripe signature received in header, returning 400 Bad Request');
    return {
      statusCode: 400
    };
  }

  const sig = event.headers['stripe-signature'];

  // require an event body
  if (!event.body) {
    console.log('no event body received in POST, returning 400 Bad Request');
    return {
      statusCode: 400
    };
  }

  // decode payload
  let payload = event.body;
  if (event.isBase64Encoded)
    payload = Buffer.from(event.body, 'base64').toString();

  // construct a Stripe Webhook event
  const config = await loadConfig();
  const stripe = require('stripe')(config.stripe_api_secret_key);

  try {
    let ev = stripe.webhooks.constructEvent(payload, sig, config.webhook_signing_secret);
  } catch (err) {
    console.log('error creating Stripe Webhook event');
    console.log(err);
    return {
      statusCode: 400
    };
  }

  // ...TODO...

  return {
    statusCode: 200
  };
}
```

Stripe Webhookのイベントまで正常に作成したら、次はCheckoutセッションのステータス変化を確認します。決済に関して以下の3つのイベントを対応するのが一般的です。

1. `checkout.session.completed` : Stripe Checkoutで決済が行われました。支払い方法によって、決済が完了になってない可能性があります。クレジットカードの場合は基本的に `payment_status` が `paid` になるので、決済完了ということになります。
2. `checkout.session.async_payment_succeeded` : `completed` のイベントで決済が未完了だったのが、決済完了となりました。
3. `checkout.session.async_payment_failed` : `completed` のイベントで決済が未完了だったのが、決済失敗となりました。

この3つのイベントを対応するには[Stripeのサンプルコード](https://stripe.com/docs/payments/checkout/fulfill-orders)とほぼ同様に行ってます。

```
const createOrder = async function(session) {
  // we (TDF) don't need to do anything here
}

const fulfillOrder = async function(session) {
  // send ticket info to customer by email
  console.log('customer email is: ' + session.customer_details.email);
}

const emailCustomerAboutFailedPayment = async function(session) {
  // send email about failed payment
}

exports.handler = async (event) => {
  // ...
  const session = ev.data.object;
  switch (ev.type) {
    case 'checkout.session.completed':
      // save an order in your database, marked as 'awaiting payment'
      await createOrder(session);

      // check if the order is paid (e.g., from a card payment)
      // a delayed notification payment will have an `unpaid` status
      if (session.payment_status === 'paid') {
        await fulfillOrder(session);
      }
      break;

    case 'checkout.session.async_payment_succeeded':
      // fulfill the purchase...
      await fulfillOrder(session);
      break;

    case 'checkout.session.async_payment_failed':
      // send an email to the customer asking them to retry their order
      await emailCustomerAboutFailedPayment(session);
      break;
  }
```

`createOrder()` 、`fulfillOrder()` 、そして `emailCustomerAboutFailedPayment()` の3つの関数を実装することでWebhookの対応は完了になります。

もしWebhookがエラーで `HTTP 2xx` 以外のレスポンスを返された場合、Stripe側では時間を置いてから自動的にリトライされます。詳しくは[Stripe Webhook Best Practices](https://stripe.com/docs/webhooks/best-practices)を確認してください。

ここまで実装ができてれば、Stripe Checkoutの対応は完了になります。おめでとうございます！

## API GatewayのCustom Domainで複数エンドポイントをひとつに統合

今回の実装では、Stripe CheckoutのURL生成とStripe Webhookの2つのエンドポイントがあります。もちろんAPI Gatewayで払い出されたURL ( `https://7q6f1e5os2.execute-api.ap-northeast-1...` )をそのまま使えますが、 `stripe.tokyodemofest.jp` などの名前を付けられたサブドメインに統合するとURLの見た目が良くなります。

![API Gateway Custom Domain](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2282314/4d7d9904-d2c5-c2b6-9e48-0f59db22f319.png)

こんな感じで `checkout` と `fulfill` の2つのLambdaとAPI GatewayがCustom Domainの1つにまとめています。

## TDFで初めてStripeを実装しての感想

正直、Stripe Checkoutをサーバーレスで実装するのはとても簡単でした。コードを書く量が少ないので、本当に数十行だけで自分のWebサイトから決済ができるようになります。

しかも、CheckoutやWebhookを実装してる際はStripe UIでAPIのHTTPリクエストとレスポンスとログ情報まで細かく見れて、Dashboardでグラフがとてもわかりやすいです。

ひとつ欲を言えば、テスト環境で作成した商品を簡単に本番モードに持って行きたいです。Webhookは「テストエンドポイントをインポート」する機能がありますが、~~商品ではできないのがちょっとだけ残念です。テスト環境で作成した商品をもう一度すべて本番モードで作り直す必要があります。~~

【※: 投稿してから知りましたが、実は商品詳細ページには「本番環境にコピー」というボタンがあります。テスト環境で作成したすべての商品を一気に本番環境までインポートするのはできないようですが、1つずつ商品をコピーするのは可能です。】

長年PayPalと戦っていたので、もっと早く乗り換えれば良かったと後悔しています（笑）

最後まで読んでくれてありがとうございました。何か質問やコメントがあれば、ぜひどうぞよろしくお願いいたします！