Nuxtstop

For all things nuxt.js

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

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

[ This post is available in English here. ]

本ブログは2021年Stripe Advent Calendar 2021の12月3日分のエントリーです。


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

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

Tokyo Demo Festについて

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

デモについての解説はこちらをご参照ください

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

システム設計

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

Serverless Stripe Diagram

Visitorは最初にAmplifyで公開されているTDFのWebサイトから入ります。2種類のチケットがあるのでどちらかを選択し、Stripe Checkoutへ移行します。決済完了時はStripeからのWebhookが呼び出され、チケット情報とVotekeyがStripeでの購入時に入力されたメールアドレスに送信されます。Visitorはメールで配布されたVotekeyを使い、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

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

TDF Stripe Shipping

Stripe APIキーのセキュリティ

Stripe APIキーは秘密情報なので、ソースコードに書き込んだりすると情報漏えいの要因になります。私のいつもの実装パターンですが、Lambdaを使ってる時はAWS Systems ManagerのParameter Storeに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"}
Enter fullscreen mode Exit fullscreen mode

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);
  // ...
}
Enter fullscreen mode Exit fullscreen mode

HTML側の購入ボタン作成

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

TDF Visitor Ticket

TDF Bronze Supporter

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

TDF Ticket HTML

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;
}
Enter fullscreen mode Exit fullscreen mode

セッションデータの 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')) {
      // ...
    }
  }
Enter fullscreen mode Exit fullscreen mode

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

  let items = [ {
    price: config.product_visitor_ticket,
    quantity: 1
  } ];
Enter fullscreen mode Exit fullscreen mode

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

Stripe Checkout Visitor

では、次に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
  } ];
Enter fullscreen mode Exit fullscreen mode

あとは、Bronze Supporterチケットの場合はTシャツの配送に住所を入力してもらう必要があります。Checkoutセッションデータに配送料金の shipping_rates と配送対象国(どの国への配送が可能)を shipping_address_collectionallowed_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(',')
    }
  } );
Enter fullscreen mode Exit fullscreen mode

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

Stripe Checkout Bronze

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

Stripe Webhookの実装

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

TDF Stripe Webhook

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
  };
}
Enter fullscreen mode Exit fullscreen mode

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

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

この3つのイベントを対応するにはStripeのサンプルコードとほぼ同様に行ってます。

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;
  }
Enter fullscreen mode Exit fullscreen mode

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

もしWebhookがエラーで HTTP 2xx 以外のレスポンスを返された場合、Stripe側では時間を置いてから自動的にリトライされます。詳しくはStripe Webhook 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

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

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

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

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

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

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

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

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