1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Posted at

本ブログは2021年Stripe Advent Calendar 2021の12月3日分のエントリーです。元々dev.toに投稿されましたが、2022年11月14日にQiitaにクロスポストの移行・再投稿いたしました。

[ This post is available in English here. ]


皆さん、こんにちは!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"}

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
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;
}

セッションデータの 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

では、次に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_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(',')
    }
  } );

配送情報がセッションデータに含まれると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
  };
}

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;
  }

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と戦っていたので、もっと早く乗り換えれば良かったと後悔しています(笑)

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

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?